edycja tankowań

This commit is contained in:
Mateusz Gruszczyński
2026-07-14 13:33:28 +02:00
parent 5980a61a7e
commit d2f0077e86
6 changed files with 166 additions and 2 deletions
+134 -1
View File
@@ -4,7 +4,7 @@ from decimal import Decimal
from functools import wraps from functools import wraps
from flask import Blueprint, render_template, request, redirect, url_for, flash, jsonify, abort from flask import Blueprint, render_template, request, redirect, url_for, flash, jsonify, abort
from flask_login import login_required, current_user from flask_login import login_required, current_user
from sqlalchemy import extract, or_ from sqlalchemy import extract, or_, and_
from sqlalchemy.exc import IntegrityError from sqlalchemy.exc import IntegrityError
from .extensions import db from .extensions import db
from .models import User, Vehicle, FuelEntry, CompanySettings, AppSetting, OrlenPrice, FuelCardPolicy, FuelStationCompany, FuelStationPoint, FuelCard, FuelCardStationRule from .models import User, Vehicle, FuelEntry, CompanySettings, AppSetting, OrlenPrice, FuelCardPolicy, FuelStationCompany, FuelStationPoint, FuelCard, FuelCardStationRule
@@ -43,6 +43,63 @@ def roles(*allowed):
return wrapped return wrapped
return deco return deco
def fuel_entry_accessible(entry):
if current_user.role == "admin":
return True
if current_user.role != "boss" or not current_user.company_id:
return False
company_id = entry.vehicle.company_id or (entry.vehicle.owner.company_id if entry.vehicle and entry.vehicle.owner else None)
return company_id == current_user.company_id
def recalculate_vehicle_odometer(vehicle):
latest = (db.session.query(db.func.max(FuelEntry.odometer))
.filter(FuelEntry.vehicle_id == vehicle.id)
.scalar())
vehicle.current_odometer = int(latest or 0)
def parse_fuel_entry_form(entry):
vehicle_id = request.form.get("vehicle_id", type=int)
vehicle = db.session.get(Vehicle, vehicle_id)
if not vehicle:
raise ValueError("Nie znaleziono pojazdu")
if current_user.role == "boss" and (vehicle.company_id or vehicle.owner.company_id) != current_user.company_id:
abort(403)
fuel_type = (request.form.get("fuel_type") or "").upper()
if fuel_type not in FUEL_TYPES:
raise ValueError("Nieprawidłowy rodzaj paliwa")
fueled_at = datetime.fromisoformat(request.form.get("fueled_at", ""))
liters = Decimal(request.form.get("liters", ""))
price = Decimal(request.form.get("price_per_liter", ""))
odometer = int(request.form.get("odometer", ""))
if liters <= 0:
raise ValueError("Liczba litrów musi być większa od zera")
if price < 0 or odometer < 0:
raise ValueError("Cena i stan licznika nie mogą być ujemne")
station_company_id = request.form.get("station_company_id", type=int)
station_company = db.session.get(FuelStationCompany, station_company_id) if station_company_id else None
fuel_card_id = request.form.get("fuel_card_id", type=int)
fuel_card = db.session.get(FuelCard, fuel_card_id) if fuel_card_id else None
company_id = vehicle.company_id or vehicle.owner.company_id
if fuel_card and fuel_card.company_id and fuel_card.company_id != company_id:
raise ValueError("Wybrana karta nie należy do firmy pojazdu")
entry.vehicle = vehicle
entry.fueled_at = fueled_at
entry.liters = liters
entry.price_per_liter = price
entry.fuel_type = fuel_type
entry.odometer = odometer
entry.station_company = station_company
entry.station = station_company.company_name if station_company else (request.form.get("station") or "").strip() or None
entry.invoice_number = (request.form.get("invoice_number") or "").strip() or None
entry.used_fuel_card = "used_fuel_card" in request.form
entry.fuel_card = fuel_card if entry.used_fuel_card else None
wholesale_price = (request.form.get("wholesale_price") or "").strip()
entry.wholesale_price = Decimal(wholesale_price) if wholesale_price else None
entry.wholesale_source = (request.form.get("wholesale_source") or "").strip() or None
return vehicle
def accessible_vehicles(): def accessible_vehicles():
if current_user.role == "admin": if current_user.role == "admin":
return Vehicle.query.order_by(Vehicle.name).all() return Vehicle.query.order_by(Vehicle.name).all()
@@ -374,6 +431,82 @@ def orlen_sync():
except Exception as exc: except Exception as exc:
db.session.rollback(); return jsonify({"ok": False, "error": str(exc)}), 502 db.session.rollback(); return jsonify({"ok": False, "error": str(exc)}), 502
@main.get("/admin/fuel-entries")
@login_required
@roles("boss", "admin")
def admin_fuel_entries():
q = request.args.get("q", "").strip()
month = request.args.get("month", "").strip()
company_id = request.args.get("company_id", type=int)
page = request.args.get("page", 1, type=int)
query = FuelEntry.query.join(Vehicle).join(User, Vehicle.owner_id == User.id)
if current_user.role == "boss":
query = query.filter(or_(Vehicle.company_id == current_user.company_id, and_(Vehicle.company_id.is_(None), User.company_id == current_user.company_id)))
company_id = current_user.company_id
elif company_id:
query = query.filter(or_(Vehicle.company_id == company_id, and_(Vehicle.company_id.is_(None), User.company_id == company_id)))
if month:
try:
year, mon = map(int, month.split("-"))
query = query.filter(extract("year", FuelEntry.fueled_at) == year, extract("month", FuelEntry.fueled_at) == mon)
except ValueError:
month = ""
if q:
like = f"%{q}%"
query = query.outerjoin(FuelStationCompany, FuelEntry.station_company_id == FuelStationCompany.id).filter(or_(
Vehicle.name.ilike(like), Vehicle.registration.ilike(like), FuelEntry.station.ilike(like),
FuelEntry.invoice_number.ilike(like), User.name.ilike(like), User.email.ilike(like),
FuelStationCompany.company_name.ilike(like), FuelStationCompany.brand_name.ilike(like)
))
entries_page = query.order_by(FuelEntry.fueled_at.desc(), FuelEntry.id.desc()).paginate(page=page, per_page=25, error_out=False)
companies = CompanySettings.query.order_by(CompanySettings.name).all() if current_user.role == "admin" else [current_user.company]
vehicles_q = Vehicle.query.join(User, Vehicle.owner_id == User.id)
cards_q = FuelCard.query.filter_by(active=True)
if current_user.role == "boss":
vehicles_q = vehicles_q.filter(or_(Vehicle.company_id == current_user.company_id, and_(Vehicle.company_id.is_(None), User.company_id == current_user.company_id)))
cards_q = cards_q.filter_by(company_id=current_user.company_id)
elif company_id:
vehicles_q = vehicles_q.filter(or_(Vehicle.company_id == company_id, and_(Vehicle.company_id.is_(None), User.company_id == company_id)))
cards_q = cards_q.filter_by(company_id=company_id)
return render_template("admin_fuel_entries.html", entries_page=entries_page, q=q, month=month, company_id=company_id, companies=companies, vehicles=vehicles_q.order_by(Vehicle.name).all(), stations=FuelStationCompany.query.filter_by(active=True).order_by(FuelStationCompany.brand_name, FuelStationCompany.company_name).all(), fuel_cards=cards_q.order_by(FuelCard.name).all())
@main.put("/api/fuel-entries/<int:entry_id>")
@login_required
@roles("boss", "admin")
def update_fuel_entry(entry_id):
entry = FuelEntry.query.get_or_404(entry_id)
if not fuel_entry_accessible(entry):
abort(403)
old_vehicle = entry.vehicle
try:
new_vehicle = parse_fuel_entry_form(entry)
db.session.flush()
recalculate_vehicle_odometer(old_vehicle)
if new_vehicle.id != old_vehicle.id:
recalculate_vehicle_odometer(new_vehicle)
db.session.commit()
except (ValueError, ArithmeticError) as exc:
db.session.rollback()
return jsonify({"ok": False, "error": str(exc)}), 400
return jsonify({"ok": True, "message": "Zaktualizowano tankowanie"})
@main.delete("/api/fuel-entries/<int:entry_id>")
@login_required
@roles("boss", "admin")
def delete_fuel_entry(entry_id):
entry = FuelEntry.query.get_or_404(entry_id)
if not fuel_entry_accessible(entry):
abort(403)
vehicle = entry.vehicle
db.session.delete(entry)
db.session.flush()
recalculate_vehicle_odometer(vehicle)
db.session.commit()
return jsonify({"ok": True, "message": "Usunięto tankowanie"})
@main.route("/admin", methods=["GET","POST"]) @main.route("/admin", methods=["GET","POST"])
@login_required @login_required
@roles("boss", "admin") @roles("boss", "admin")
+1
View File
@@ -0,0 +1 @@
(()=>{const F=FuelTrack;F.bindFuelEntryModal=()=>{const form=F.qs('#fuel-entry-edit-form'),del=F.qs('#fuel-entry-delete-btn');if(!form)return;F.qsa('.fuel-entry-edit-btn').forEach(btn=>{if(btn.dataset.bound)return;btn.dataset.bound='1';btn.addEventListener('click',()=>{const d=JSON.parse(btn.dataset.entry);form.action=`/api/fuel-entries/${d.id}`;form.dataset.entryId=d.id;['vehicle_id','fueled_at','fuel_type','liters','price_per_liter','odometer','station_company_id','station','invoice_number','fuel_card_id','wholesale_price','wholesale_source'].forEach(k=>{if(form.elements[k])form.elements[k].value=d[k]??''});form.elements.used_fuel_card.checked=Boolean(d.used_fuel_card)})});if(del&&!del.dataset.bound){del.dataset.bound='1';del.addEventListener('click',async()=>{const id=form.dataset.entryId;if(!id||!confirm('Usunąć to tankowanie? Tej operacji nie można cofnąć.'))return;const old=del.innerHTML;del.disabled=true;del.innerHTML='Usuwanie…';try{const r=await fetch(`/api/fuel-entries/${id}`,{method:'DELETE',headers:{'X-Requested-With':'XMLHttpRequest','Accept':'application/json'}}),d=await r.json();if(!r.ok||!d.ok)throw new Error(d.error||d.message||'Nie udało się usunąć tankowania.');F.notify(d.message);bootstrap.Modal.getInstance(form.closest('.modal'))?.hide();await F.replaceMain(location.href)}catch(e){F.notify(e.message,'danger')}finally{del.disabled=false;del.innerHTML=old}})}}})();
+1 -1
View File
@@ -1 +1 @@
FuelTrack.initPage=()=>{FuelTrack.bindAjaxForms();FuelTrack.bindFuelPreview();FuelTrack.bindOrlenSync();FuelTrack.bindUserModal();FuelTrack.bindAuth();FuelTrack.bindStationLiveSearch();FuelTrack.bindStationFavoriteToggles();FuelTrack.bindStationInlineCompany();FuelTrack.bindLpgRegionPicker();FuelTrack.bindFavoriteStations();FuelTrack.bindStationProposalSelect();FuelTrack.bindStationPoints()};window.addEventListener('popstate',()=>FuelTrack.replaceMain(location.href));document.addEventListener('DOMContentLoaded',()=>FuelTrack.initPage()); FuelTrack.initPage=()=>{FuelTrack.bindAjaxForms();FuelTrack.bindFuelPreview();FuelTrack.bindOrlenSync();FuelTrack.bindUserModal();FuelTrack.bindFuelEntryModal();FuelTrack.bindAuth();FuelTrack.bindStationLiveSearch();FuelTrack.bindStationFavoriteToggles();FuelTrack.bindStationInlineCompany();FuelTrack.bindLpgRegionPicker();FuelTrack.bindFavoriteStations();FuelTrack.bindStationProposalSelect();FuelTrack.bindStationPoints()};window.addEventListener('popstate',()=>FuelTrack.replaceMain(location.href));document.addEventListener('DOMContentLoaded',()=>FuelTrack.initPage());
+1
View File
@@ -2,4 +2,5 @@
<li class="nav-item"><a class="nav-link {% if request.endpoint=='main.admin_companies' %}active{% endif %}" href="{{url_for('main.admin_companies')}}">Firmy i karty</a></li> <li class="nav-item"><a class="nav-link {% if request.endpoint=='main.admin_companies' %}active{% endif %}" href="{{url_for('main.admin_companies')}}">Firmy i karty</a></li>
{% if current_user.role=='admin' %}<li class="nav-item"><a class="nav-link {% if request.endpoint=='main.admin_application' %}active{% endif %}" href="{{url_for('main.admin_application')}}">Aplikacja</a></li>{% endif %} {% if current_user.role=='admin' %}<li class="nav-item"><a class="nav-link {% if request.endpoint=='main.admin_application' %}active{% endif %}" href="{{url_for('main.admin_application')}}">Aplikacja</a></li>{% endif %}
<li class="nav-item"><a class="nav-link {% if request.endpoint=='main.admin_users' %}active{% endif %}" href="{{url_for('main.admin_users')}}">Użytkownicy</a></li> <li class="nav-item"><a class="nav-link {% if request.endpoint=='main.admin_users' %}active{% endif %}" href="{{url_for('main.admin_users')}}">Użytkownicy</a></li>
<li class="nav-item"><a class="nav-link {% if request.endpoint=='main.admin_fuel_entries' %}active{% endif %}" href="{{url_for('main.admin_fuel_entries')}}">Tankowania</a></li>
</ul> </ul>
+28
View File
@@ -0,0 +1,28 @@
{% extends 'base.html' %}{% block content %}
<div class="mb-4"><h1 class="h2 mb-1">Administracja</h1><p class="text-body-secondary mb-0">Korekta i usuwanie zapisanych tankowań.</p></div>
{% include '_admin_nav.html' %}
<div class="card"><div class="card-body">
<form method="get" class="row g-2 mb-4 ajax-nav-form" action="{{url_for('main.admin_fuel_entries')}}">
{% if current_user.role=='admin' %}<div class="col-lg-3"><label class="form-label">Firma</label><select class="form-select" name="company_id"><option value="">Wszystkie firmy</option>{% for c in companies %}<option value="{{c.id}}" {% if company_id==c.id %}selected{% endif %}>{{c.name}}</option>{% endfor %}</select></div>{% endif %}
<div class="col-lg-3"><label class="form-label">Miesiąc</label><input class="form-control" type="month" name="month" value="{{month}}"></div>
<div class="col-lg-4"><label class="form-label">Szukaj</label><input class="form-control" name="q" value="{{q}}" placeholder="Auto, rejestracja, stacja, faktura, użytkownik"></div>
<div class="col-lg-2 d-flex align-items-end"><button class="btn btn-primary w-100">Filtruj</button></div>
</form>
<div class="table-responsive"><table class="table table-hover align-middle"><thead><tr><th>Data</th><th>Pojazd</th><th>Użytkownik</th><th>Stacja</th><th>Litry</th><th>Cena</th><th>Licznik</th><th class="text-end">Akcja</th></tr></thead><tbody>
{% for e in entries_page.items %}<tr><td>{{e.fueled_at.strftime('%d.%m.%Y %H:%M')}}</td><td><strong>{{e.vehicle.name}}</strong><div class="small text-body-secondary">{{e.vehicle.registration}}</div></td><td>{{e.user.name}}</td><td>{{e.station or '—'}}</td><td>{{e.liters}}</td><td>{{e.price_per_liter}} zł/l</td><td>{{e.odometer}} km</td><td class="text-end"><button type="button" class="btn btn-sm btn-primary fuel-entry-edit-btn" data-bs-toggle="modal" data-bs-target="#fuelEntryEditModal" data-entry='{{ {"id":e.id,"vehicle_id":e.vehicle_id,"fueled_at":e.fueled_at.strftime("%Y-%m-%dT%H:%M"),"fuel_type":e.fuel_type,"liters":e.liters|string,"price_per_liter":e.price_per_liter|string,"odometer":e.odometer,"station_company_id":e.station_company_id or "","station":e.station or "","invoice_number":e.invoice_number or "","used_fuel_card":e.used_fuel_card,"fuel_card_id":e.fuel_card_id or "","wholesale_price":e.wholesale_price|string if e.wholesale_price is not none else "","wholesale_source":e.wholesale_source or ""}|tojson|forceescape }}'>Edytuj</button></td></tr>
{% else %}<tr><td colspan="8" class="text-center text-body-secondary py-4">Brak tankowań spełniających kryteria.</td></tr>{% endfor %}</tbody></table></div>
{% if entries_page.pages > 1 %}<nav><ul class="pagination mb-0">{% for p in entries_page.iter_pages() %}{% if p %}<li class="page-item {% if p==entries_page.page %}active{% endif %}"><a class="page-link ajax-nav-link" href="{{url_for('main.admin_fuel_entries',page=p,q=q,month=month,company_id=company_id)}}">{{p}}</a></li>{% else %}<li class="page-item disabled"><span class="page-link"></span></li>{% endif %}{% endfor %}</ul></nav>{% endif %}
</div></div>
<div class="modal fade" id="fuelEntryEditModal" tabindex="-1"><div class="modal-dialog modal-lg modal-dialog-scrollable"><div class="modal-content"><form id="fuel-entry-edit-form" class="ajax-form" data-method="PUT"><div class="modal-header"><h2 class="modal-title fs-5">Edytuj tankowanie</h2><button type="button" class="btn-close" data-bs-dismiss="modal"></button></div><div class="modal-body"><div class="row g-3">
<div class="col-md-6"><label class="form-label">Pojazd</label><select class="form-select" name="vehicle_id" required>{% for v in vehicles %}<option value="{{v.id}}">{{v.name}} · {{v.registration}}</option>{% endfor %}</select></div>
<div class="col-md-6"><label class="form-label">Data i godzina</label><input class="form-control" type="datetime-local" name="fueled_at" required></div>
<div class="col-md-4"><label class="form-label">Paliwo</label><select class="form-select" name="fuel_type">{% for f in ['PB95','PB98','DIESEL','LPG'] %}<option>{{f}}</option>{% endfor %}</select></div>
<div class="col-md-4"><label class="form-label">Litry</label><input class="form-control" type="number" step="0.001" min="0.001" name="liters" required></div>
<div class="col-md-4"><label class="form-label">Cena za litr</label><input class="form-control" type="number" step="0.0001" min="0" name="price_per_liter" required></div>
<div class="col-md-4"><label class="form-label">Stan licznika</label><input class="form-control" type="number" min="0" name="odometer" required></div>
<div class="col-md-8"><label class="form-label">Sieć stacji</label><select class="form-select" name="station_company_id"><option value="">Inna / własna nazwa</option>{% for s in stations %}<option value="{{s.id}}">{{s.display_name}}{% if s.company_name != s.display_name %} — {{s.company_name}}{% endif %}</option>{% endfor %}</select></div>
<div class="col-md-6"><label class="form-label">Nazwa stacji</label><input class="form-control" name="station"></div><div class="col-md-6"><label class="form-label">Numer faktury</label><input class="form-control" name="invoice_number"></div>
<div class="col-md-6"><label class="form-label">Karta paliwowa</label><select class="form-select" name="fuel_card_id"><option value="">Brak</option>{% for c in fuel_cards %}<option value="{{c.id}}">{{c.name}} · {{c.provider}}</option>{% endfor %}</select><div class="form-check mt-2"><input class="form-check-input" type="checkbox" name="used_fuel_card" id="edit-used-card"><label class="form-check-label" for="edit-used-card">Użyto karty paliwowej</label></div></div>
<div class="col-md-3"><label class="form-label">Cena hurtowa</label><input class="form-control" type="number" step="0.0001" min="0" name="wholesale_price"></div><div class="col-md-3"><label class="form-label">Źródło ceny</label><input class="form-control" name="wholesale_source"></div>
</div></div><div class="modal-footer"><button type="button" class="btn btn-danger me-auto" id="fuel-entry-delete-btn">Usuń tankowanie</button><button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Anuluj</button><button class="btn btn-primary">Zapisz zmiany</button></div></form></div></div></div>
{% endblock %}
+1
View File
@@ -55,6 +55,7 @@
<script src="{{ static_url('js/fuel.js') }}"></script> <script src="{{ static_url('js/fuel.js') }}"></script>
<script src="{{ static_url('js/auth.js') }}"></script> <script src="{{ static_url('js/auth.js') }}"></script>
<script src="{{ static_url('js/users.js') }}"></script> <script src="{{ static_url('js/users.js') }}"></script>
<script src="{{ static_url('js/fuel_entries.js') }}"></script>
<script src="{{ static_url('js/init.js') }}"></script> <script src="{{ static_url('js/init.js') }}"></script>
{% block scripts %}{% endblock %} {% block scripts %}{% endblock %}
</body></html> </body></html>