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 flask import Blueprint, render_template, request, redirect, url_for, flash, jsonify, abort
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 .extensions import db
from .models import User, Vehicle, FuelEntry, CompanySettings, AppSetting, OrlenPrice, FuelCardPolicy, FuelStationCompany, FuelStationPoint, FuelCard, FuelCardStationRule
@@ -43,6 +43,63 @@ def roles(*allowed):
return wrapped
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():
if current_user.role == "admin":
return Vehicle.query.order_by(Vehicle.name).all()
@@ -374,6 +431,82 @@ def orlen_sync():
except Exception as exc:
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"])
@login_required
@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>
{% 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_fuel_entries' %}active{% endif %}" href="{{url_for('main.admin_fuel_entries')}}">Tankowania</a></li>
</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/auth.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>
{% block scripts %}{% endblock %}
</body></html>