diff --git a/app/main.py b/app/main.py index 1b0c651..c372238 100644 --- a/app/main.py +++ b/app/main.py @@ -101,6 +101,51 @@ def parse_fuel_entry_form(entry): entry.wholesale_source = (request.form.get("wholesale_source") or "").strip() or None return vehicle + +def calculate_entry_settlement(entry, fallback_settings=None): + """Calculate retail, invoice and effective company cost for one fuel entry.""" + entry_settings = entry.vehicle.company or (entry.vehicle.owner.company if entry.vehicle and entry.vehicle.owner else None) or fallback_settings or CompanySettings.query.first() + vat_rate = effective_vat_rate(entry_settings.vat_rate, entry.fueled_at) + vat_deduction_percent = entry_settings.vat_deduction_percent + retail_price = Decimal(entry.price_per_liter) + rule = FuelCardStationRule.query.filter_by( + fuel_card_id=entry.fuel_card_id, + station_company_id=entry.station_company_id, + ).first() if entry.used_fuel_card and entry.fuel_card_id and entry.station_company_id else None + uses_last_price = bool(rule and rule.use_orlen_last_price and entry.wholesale_price is not None) + + if uses_last_price: + settlement_net = Decimal(entry.wholesale_price) + else: + settlement_net = retail_price / (Decimal("1") + Decimal(str(vat_rate)) / Decimal("100")) + if rule: + settlement_net *= Decimal("1") - Decimal(rule.discount_net_percent or 0) / Decimal("100") + settlement_net += Decimal(rule.surcharge_net_per_liter or 0) + settlement_net = max(settlement_net, Decimal("0")) + + price_costs = calculate_net_price_costs(settlement_net, vat_rate, vat_deduction_percent) + liters = Decimal(entry.liters) + retail_gross = retail_price * liters + invoice_gross = price_costs["invoice_gross"] * liters + deductible_vat = price_costs["deductible_vat"] * liters + effective_cost = price_costs["effective_cost"] * liters + last_price_saving = retail_gross - invoice_gross if uses_last_price else Decimal("0") + return { + "liters": liters, + "retail_price": retail_price, + "settlement_net": settlement_net, + "invoice_price": price_costs["invoice_gross"], + "effective_price": price_costs["effective_cost"], + "retail_gross": retail_gross, + "invoice_gross": invoice_gross, + "deductible_vat": deductible_vat, + "effective_cost": effective_cost, + "last_price_saving": last_price_saving, + "uses_last_price": uses_last_price, + "vat_rate": Decimal(str(vat_rate)), + "vat_deduction_percent": Decimal(str(vat_deduction_percent)), + } + def accessible_vehicles(): if current_user.role == "admin": return Vehicle.query.order_by(Vehicle.name).all() @@ -118,7 +163,7 @@ def dashboard(): try: year, mon = map(int, month.split("-")) except ValueError: year, mon = datetime.now().year, datetime.now().month; month=f"{year:04d}-{mon:02d}" entries = FuelEntry.query.filter(FuelEntry.vehicle_id.in_(ids), extract("year", FuelEntry.fueled_at)==year, extract("month", FuelEntry.fueled_at)==mon).order_by(FuelEntry.fueled_at.desc()).all() if ids else [] - totals = {"gross": Decimal("0"), "deductible_vat": Decimal("0"), "final_cost": Decimal("0"), "liters": Decimal("0")} + totals = {"gross": Decimal("0"), "deductible_vat": Decimal("0"), "final_cost": Decimal("0"), "liters": Decimal("0"), "last_price_saving": Decimal("0")} by_vehicle = defaultdict(lambda: {"gross": 0, "payable": 0, "liters": 0}); invoices = defaultdict(lambda: {"gross": 0, "payable": 0, "count": 0}); settlements = {} for e in entries: entry_settings = e.vehicle.company or settings @@ -148,6 +193,8 @@ def dashboard(): totals["deductible_vat"] += deductible_vat totals["final_cost"] += effective_gross totals["liters"] += liters + last_price_saving = Decimal(str(e.gross)) - invoice_gross if uses_station_last_price else Decimal("0") + totals["last_price_saving"] += last_price_saving settlements[e.id] = { "normal_price": normal_price, "net_price": float(settlement_net), @@ -157,6 +204,7 @@ def dashboard(): "invoice_gross": float(invoice_gross), "payable_gross": float(effective_gross), "uses_last_price": uses_station_last_price, + "last_price_saving": float(last_price_saving), "vat_rate": float(vat_rate), "vat_deduction_percent": float(vat_deduction_percent), } @@ -456,6 +504,88 @@ def orlen_sync(): + +@main.get("/admin/reports") +@login_required +@roles("boss", "admin") +def admin_reports(): + today = date.today() + period = request.args.get("period", "month") + company_id = request.args.get("company_id", type=int) + month = request.args.get("month", today.strftime("%Y-%m")) + year = request.args.get("year", today.year, type=int) + date_from_raw = request.args.get("date_from", "") + date_to_raw = request.args.get("date_to", "") + + if current_user.role == "boss": + company_id = current_user.company_id + companies = CompanySettings.query.order_by(CompanySettings.name).all() if current_user.role == "admin" else [current_user.company] + + try: + if period == "year": + start_date, end_date = date(year, 1, 1), date(year, 12, 31) + elif period == "custom": + start_date = date.fromisoformat(date_from_raw) + end_date = date.fromisoformat(date_to_raw) + if end_date < start_date: + raise ValueError + else: + period = "month" + selected_year, selected_month = map(int, month.split("-")) + start_date = date(selected_year, selected_month, 1) + if selected_month == 12: + end_date = date(selected_year, 12, 31) + else: + end_date = date(selected_year, selected_month + 1, 1) + end_date = date.fromordinal(end_date.toordinal() - 1) + except (TypeError, ValueError): + period = "month" + month = today.strftime("%Y-%m") + start_date = date(today.year, today.month, 1) + next_month = date(today.year + (today.month == 12), 1 if today.month == 12 else today.month + 1, 1) + end_date = date.fromordinal(next_month.toordinal() - 1) + + query = FuelEntry.query.join(Vehicle).join(User, Vehicle.owner_id == User.id).filter( + FuelEntry.fueled_at >= datetime.combine(start_date, datetime.min.time()), + FuelEntry.fueled_at < datetime.combine(date.fromordinal(end_date.toordinal() + 1), datetime.min.time()), + ) + if company_id: + query = query.filter(or_(Vehicle.company_id == company_id, and_(Vehicle.company_id.is_(None), User.company_id == company_id))) + entries = query.order_by(FuelEntry.fueled_at.asc()).all() + + zero = Decimal("0") + totals = {"retail": zero, "invoice": zero, "effective": zero, "vat_deducted": zero, "saving": zero, "liters": zero, "count": 0, "last_price_count": 0} + by_month = defaultdict(lambda: {"retail": 0.0, "invoice": 0.0, "effective": 0.0, "saving": 0.0, "liters": 0.0, "count": 0}) + by_vehicle = defaultdict(lambda: {"retail": 0.0, "invoice": 0.0, "effective": 0.0, "saving": 0.0, "liters": 0.0, "count": 0}) + by_company = defaultdict(lambda: {"retail": 0.0, "invoice": 0.0, "effective": 0.0, "saving": 0.0, "liters": 0.0, "count": 0}) + + for entry in entries: + row = calculate_entry_settlement(entry) + totals["retail"] += row["retail_gross"] + totals["invoice"] += row["invoice_gross"] + totals["effective"] += row["effective_cost"] + totals["vat_deducted"] += row["deductible_vat"] + totals["saving"] += row["last_price_saving"] + totals["liters"] += row["liters"] + totals["count"] += 1 + totals["last_price_count"] += int(row["uses_last_price"]) + company = entry.vehicle.company or entry.vehicle.owner.company + keys = ((by_month, entry.fueled_at.strftime("%Y-%m")), (by_vehicle, entry.vehicle.name), (by_company, company.name if company else "Bez firmy")) + for bucket, key in keys: + bucket[key]["retail"] += float(row["retail_gross"]) + bucket[key]["invoice"] += float(row["invoice_gross"]) + bucket[key]["effective"] += float(row["effective_cost"]) + bucket[key]["saving"] += float(row["last_price_saving"]) + bucket[key]["liters"] += float(row["liters"]) + bucket[key]["count"] += 1 + + totals["saving_percent"] = (totals["saving"] / totals["retail"] * Decimal("100")) if totals["retail"] else zero + return render_template( + "admin_reports.html", companies=[c for c in companies if c], company_id=company_id, period=period, month=month, year=year, + date_from=date_from_raw, date_to=date_to_raw, start_date=start_date, end_date=end_date, totals=totals, + by_month=dict(sorted(by_month.items())), by_vehicle=dict(sorted(by_vehicle.items())), by_company=dict(sorted(by_company.items())), + ) + @main.get("/admin/fuel-entries") @login_required @roles("boss", "admin") diff --git a/app/static/js/charts.js b/app/static/js/charts.js index 92deea7..9225b60 100644 --- a/app/static/js/charts.js +++ b/app/static/js/charts.js @@ -1 +1,35 @@ -(()=>{const F=FuelTrack;const text=()=>getComputedStyle(document.body).color;const grid=()=>getComputedStyle(document.documentElement).getPropertyValue('--bs-border-color').trim()||'rgba(0,0,0,.1)';function destroy(id){if(F.charts[id]){F.charts[id].destroy();delete F.charts[id]}}function empty(id,v){F.qs(`#${id}-empty`)?.classList.toggle('d-none',!v);F.qs(`#${id}`)?.classList.toggle('d-none',v)}F.renderBarChart=(id,data)=>{const el=F.qs(`#${id}`);if(!el)return;const labels=Object.keys(data||{});destroy(id);empty(id,!labels.length);if(!labels.length)return;F.charts[id]=new Chart(el,{type:'bar',data:{labels,datasets:[{label:'Cena detaliczna (zł)',data:labels.map(k=>data[k].gross)},{label:'Do zapłaty wg karty (zł)',data:labels.map(k=>data[k].payable)}]},options:{responsive:true,maintainAspectRatio:false,plugins:{legend:{labels:{color:text()}}},scales:{x:{ticks:{color:text()},grid:{color:grid()}},y:{beginAtZero:true,ticks:{color:text()},grid:{color:grid()}}}}})};F.renderMultiLineChart=(id,series,gross=false)=>{const el=F.qs(`#${id}`);if(!el)return;destroy(id);const keys=Object.keys(series||{}).filter(k=>(series[k]||[]).length);empty(id,!keys.length);if(!keys.length)return;const dates=[...new Set(keys.flatMap(k=>series[k].map(p=>p.date)))].sort();F.charts[id]=new Chart(el,{type:'line',data:{labels:dates,datasets:keys.map(k=>{const m=new Map(series[k].map(p=>[p.date,p.value]));return{label:`${k} ${gross?'brutto':'netto'} zł/l`,data:dates.map(d=>m.has(d)?m.get(d):null),spanGaps:true,tension:.2,pointRadius:1.5,borderWidth:2}})},options:{responsive:true,maintainAspectRatio:false,interaction:{mode:'index',intersect:false},plugins:{legend:{labels:{color:text()}}},scales:{x:{ticks:{color:text(),maxTicksLimit:12},grid:{color:grid()}},y:{ticks:{color:text()},grid:{color:grid()}}}}})}})(); +(()=>{ + const F=FuelTrack; + const text=()=>getComputedStyle(document.body).color; + const grid=()=>getComputedStyle(document.documentElement).getPropertyValue('--bs-border-color').trim()||'rgba(0,0,0,.1)'; + function destroy(id){if(F.charts[id]){F.charts[id].destroy();delete F.charts[id]}} + function empty(id,value){F.qs(`#${id}-empty`)?.classList.toggle('d-none',!value);F.qs(`#${id}`)?.classList.toggle('d-none',value)} + const commonScales=()=>({x:{ticks:{color:text()},grid:{color:grid()}},y:{beginAtZero:true,ticks:{color:text()},grid:{color:grid()}}}); + + F.renderBarChart=(id,data)=>{ + const el=F.qs(`#${id}`);if(!el)return; + const labels=Object.keys(data||{});destroy(id);empty(id,!labels.length);if(!labels.length)return; + F.charts[id]=new Chart(el,{type:'bar',data:{labels,datasets:[ + {label:'Cena fakturowa (zł)',data:labels.map(k=>data[k].gross)}, + {label:'Koszt po VAT (zł)',data:labels.map(k=>data[k].payable)} + ]},options:{responsive:true,maintainAspectRatio:false,plugins:{legend:{labels:{color:text()}}},scales:commonScales()}}); + }; + + F.renderMultiLineChart=(id,series,gross=false)=>{ + const el=F.qs(`#${id}`);if(!el)return;destroy(id); + const keys=Object.keys(series||{}).filter(k=>(series[k]||[]).length);empty(id,!keys.length);if(!keys.length)return; + const dates=[...new Set(keys.flatMap(k=>series[k].map(p=>p.date)))].sort(); + F.charts[id]=new Chart(el,{type:'line',data:{labels:dates,datasets:keys.map(k=>{const m=new Map(series[k].map(p=>[p.date,p.value]));return{label:`${k} ${gross?'brutto':'netto'} zł/l`,data:dates.map(d=>m.has(d)?m.get(d):null),spanGaps:true,tension:.2,pointRadius:1.5,borderWidth:2}})},options:{responsive:true,maintainAspectRatio:false,interaction:{mode:'index',intersect:false},plugins:{legend:{labels:{color:text()}}},scales:{x:{ticks:{color:text(),maxTicksLimit:12},grid:{color:grid()}},y:{ticks:{color:text()},grid:{color:grid()}}}}}); + }; + + F.renderCostReportChart=(id,data)=>{ + const el=F.qs(`#${id}`);if(!el)return; + const labels=Object.keys(data||{});destroy(id);empty(id,!labels.length);if(!labels.length)return; + F.charts[id]=new Chart(el,{type:'bar',data:{labels,datasets:[ + {label:'Detal z dystrybutora (zł)',data:labels.map(k=>data[k].retail)}, + {label:'Faktura wg rozliczenia (zł)',data:labels.map(k=>data[k].invoice)}, + {label:'Koszt po VAT (zł)',data:labels.map(k=>data[k].effective)}, + {label:'Oszczędność Last Price (zł)',data:labels.map(k=>data[k].saving)} + ]},options:{responsive:true,maintainAspectRatio:false,plugins:{legend:{labels:{color:text()}}},scales:commonScales()}}); + }; +})(); diff --git a/app/templates/_admin_nav.html b/app/templates/_admin_nav.html index f7b5cad..af3991a 100644 --- a/app/templates/_admin_nav.html +++ b/app/templates/_admin_nav.html @@ -3,4 +3,5 @@ {% if current_user.role=='admin' %}{% endif %} + diff --git a/app/templates/admin_reports.html b/app/templates/admin_reports.html new file mode 100644 index 0000000..392e9bb --- /dev/null +++ b/app/templates/admin_reports.html @@ -0,0 +1,32 @@ +{% extends 'base.html' %}{% block content %} +

Zestawienia kosztów

Rzeczywiste koszty, VAT i oszczędność wynikająca z rozliczenia Last Price.

+{% include '_admin_nav.html' %} +
+
+ {% if current_user.role=='admin' %}
{% endif %} +
+
+
+
+
+
+
+

Okres: {{start_date.strftime('%d.%m.%Y')}}–{{end_date.strftime('%d.%m.%Y')}}

+
+
Detal z dystrybutora{{'%.2f'|format(totals.retail)}} zł
+
Faktury Last Price*{{'%.2f'|format(totals.invoice)}} zł
+
Koszt po VAT{{'%.2f'|format(totals.effective)}} zł
+
Odliczony VAT{{'%.2f'|format(totals.vat_deducted)}} zł
+
Oszczędność Last Price{{'%.2f'|format(totals.saving)}} zł{{'%.2f'|format(totals.saving_percent)}}%
+
Tankowania / litry{{totals.count}} / {{'%.2f'|format(totals.liters)}} lLast Price: {{totals.last_price_count}}
+
+
* Faktura Last Price oznacza kwotę obliczoną z ceny Last Price netto, powiększonej o stawkę VAT firmy. Oszczędność Last Price to różnica między wartością detaliczną z dystrybutora a fakturą Last Price brutto.
+

Koszty w czasie

Brak danych dla wybranego okresu.
+
+

Według pojazdu

{% for name,row in by_vehicle.items() %}{% else %}{% endfor %}
PojazdTank.LitryDetalFaktura*Koszt po VATOszczędność
{{name}}{{row.count}}{{'%.2f'|format(row.liters)}}{{'%.2f'|format(row.retail)}} zł{{'%.2f'|format(row.invoice)}} zł{{'%.2f'|format(row.effective)}} zł{{'%.2f'|format(row.saving)}} zł
Brak danych.
+

Według firmy

{% for name,row in by_company.items() %}{% else %}{% endfor %}
FirmaTank.LitryDetalFaktura*Koszt po VATOszczędność
{{name}}{{row.count}}{{'%.2f'|format(row.liters)}}{{'%.2f'|format(row.retail)}} zł{{'%.2f'|format(row.invoice)}} zł{{'%.2f'|format(row.effective)}} zł{{'%.2f'|format(row.saving)}} zł
Brak danych.
+
+{% endblock %}{% block scripts %}{% endblock %} diff --git a/app/templates/dashboard.html b/app/templates/dashboard.html index fd0066b..a236e29 100644 --- a/app/templates/dashboard.html +++ b/app/templates/dashboard.html @@ -1,7 +1,7 @@ {% extends 'base.html' %}{% block content %}

Podsumowanie kosztów

{{ settings.name }} · VAT do odliczenia {{ settings.vat_deduction_percent }}%
{% if vat_override.enabled %}
{{ vat_override.name }}: globalny VAT {{ vat_override.rate }}%{% if vat_override.start %} od {{ vat_override.start.strftime('%d.%m.%Y') }}{% endif %}{% if vat_override.end %} do {{ vat_override.end.strftime('%d.%m.%Y') }}{% endif %}. Rozliczenia są liczone według daty tankowania.
{% endif %} -
Brutto{{ '%.2f'|format(totals.gross) }} zł
VAT odliczony{{ '%.2f'|format(totals.deductible_vat) }} zł
Koszt po VAT{{ '%.2f'|format(totals.final_cost) }} zł
Litry{{ '%.2f'|format(totals.liters) }} l
-

Koszt według pojazdu

Cena fakturowa a rzeczywisty koszt po odliczeniu VAT.

Brak danych dla wybranego miesiąca.

Okresy faktur

{% if split_enabled %}Podział według dnia {{settings.invoice_split_day}}{% else %}Podział dzienny wyłączony{% endif %}{% for key,row in invoices.items() %}{% else %}{% endfor %}
OkresPozycjeDetalKoszt po VAT
{{key}}{{row.count}}{{'%.2f'|format(row.gross)}} zł{{'%.2f'|format(row.payable)}} zł
Brak danych
-

Tankowania

{% for e in entries %}{% set s=settlements[e.id] %}{% else %}{% endfor %}
DataAutoStacjaPaliwoLitryCena detal.Last price nettoKoszt po VATVATRazem
{{e.fueled_at.strftime('%d.%m.%Y')}}{{e.vehicle.name}}{{e.station or '—'}}{{e.fuel_type}}{{e.liters}}{{'%.4f'|format(s.normal_price)}} zł/l{% if s.uses_last_price %}{{'%.4f'|format(s.net_price)}} zł/l{% elif e.wholesale_price %}{{'%.4f'|format(e.wholesale_price|float)}} zł/l{% else %}—{% endif %}{% if s.uses_last_price %}last price {% endif %}{{'%.4f'|format(s.payable_price)}} zł/l
faktura {{'%.4f'|format(s.invoice_price)}} zł/l
{{'%.2f'|format(s.vat_rate)}}%
odliczenie {{'%.0f'|format(s.vat_deduction_percent)}}%
{{'%.2f'|format(s.normal_gross)}} zł
{{'%.2f'|format(s.payable_gross)}} zł
faktura {{'%.2f'|format(s.invoice_gross)}} zł
Brak tankowań w tym miesiącu.
+
Brutto{{ '%.2f'|format(totals.gross) }} zł
VAT odliczony{{ '%.2f'|format(totals.deductible_vat) }} zł
Koszt po VAT{{ '%.2f'|format(totals.final_cost) }} zł
Litry{{ '%.2f'|format(totals.liters) }} l
Oszczędność Last Price{{ '%.2f'|format(totals.last_price_saving) }} zł
+

Koszt według pojazdu

Cena fakturowa a rzeczywisty koszt po odliczeniu VAT.

Brak danych dla wybranego miesiąca.

Okresy faktur

{% if split_enabled %}Podział według dnia {{settings.invoice_split_day}}{% else %}Podział dzienny wyłączony{% endif %}{% for key,row in invoices.items() %}{% else %}{% endfor %}
OkresPozycjeFaktura*Koszt po VAT
{{key}}{{row.count}}{{'%.2f'|format(row.gross)}} zł{{'%.2f'|format(row.payable)}} zł
Brak danych
* Kwota faktury wynika z ceny Last Price netto + VAT firmy, gdy tankowanie podlega tej regule.
+

Tankowania

{% for e in entries %}{% set s=settlements[e.id] %}{% else %}{% endfor %}
DataAutoStacjaPaliwoLitryCena detal.Last price nettoKoszt po VATVATOszczędnośćRazem
{{e.fueled_at.strftime('%d.%m.%Y')}}{{e.vehicle.name}}{{e.station or '—'}}{{e.fuel_type}}{{e.liters}}{{'%.4f'|format(s.normal_price)}} zł/l{% if s.uses_last_price %}{{'%.4f'|format(s.net_price)}} zł/l{% elif e.wholesale_price %}{{'%.4f'|format(e.wholesale_price|float)}} zł/l{% else %}—{% endif %}{% if s.uses_last_price %}last price {% endif %}{{'%.4f'|format(s.payable_price)}} zł/l
faktura {{'%.4f'|format(s.invoice_price)}} zł/l
{{'%.2f'|format(s.vat_rate)}}%
odliczenie {{'%.0f'|format(s.vat_deduction_percent)}}%
{% if s.uses_last_price %}{{'%.2f'|format(s.last_price_saving)}} zł
dzięki Last Price{% else %}—{% endif %}
{{'%.2f'|format(s.normal_gross)}} zł
{{'%.2f'|format(s.payable_gross)}} zł
faktura {{'%.2f'|format(s.invoice_gross)}} zł
Brak tankowań w tym miesiącu.
{% endblock %}{% block scripts %}{% endblock %}