Compare commits

...
20 Commits
Author SHA1 Message Date
Mateusz Gruszczyński 8648c3bda0 fix 2026-08-02 00:31:10 +02:00
Mateusz Gruszczyński 84ceb23316 fix 2026-08-02 00:19:34 +02:00
Mateusz Gruszczyński 5299a7fc66 zapamietanie akcji 2026-07-19 19:22:52 +02:00
Mateusz Gruszczyński 697ac6da30 zapamietanie akcji 2026-07-19 19:15:21 +02:00
Mateusz Gruszczyński 659f04f05f zapamietanie akcji 2026-07-19 19:00:04 +02:00
Mateusz Gruszczyński e26d9e4ff3 favicon 2026-07-14 15:28:23 +02:00
Mateusz Gruszczyński f6d588c56a favicon 2026-07-14 15:22:19 +02:00
Mateusz Gruszczyński 611b23e242 split api pythons 2026-07-14 14:57:35 +02:00
Mateusz Gruszczyński cf4398a2a0 split api pythons 2026-07-14 14:55:46 +02:00
Mateusz Gruszczyński 72757cad55 fix w kalkulacji last price 2026-07-14 14:22:08 +02:00
Mateusz Gruszczyński bb5d992913 zestawienia 2026-07-14 14:09:40 +02:00
Mateusz Gruszczyński 654bffded0 fix in import 2026-07-14 13:50:42 +02:00
Mateusz Gruszczyński 289c812ab9 fix w kalkulacji last price 2026-07-14 13:48:50 +02:00
Mateusz Gruszczyński 6f46508da4 fix w kalkulacji last price 2026-07-14 13:43:27 +02:00
Mateusz Gruszczyński d2f0077e86 edycja tankowań 2026-07-14 13:33:28 +02:00
Mateusz Gruszczyński 5980a61a7e soma improvements 2026-07-14 13:18:35 +02:00
Mateusz Gruszczyński 1f4d459a64 rewrite styles 2026-07-14 12:12:46 +02:00
Mateusz Gruszczyński de7153e298 rewrite styles 2026-07-14 11:53:03 +02:00
Mateusz Gruszczyński e4737b9289 rewrite styles 2026-07-14 11:52:43 +02:00
Mateusz Gruszczyński e200c91881 rewrite styles 2026-07-14 11:52:36 +02:00
54 changed files with 2362 additions and 679 deletions
-1
View File
@@ -21,7 +21,6 @@ BOOTSTRAP_JS_URL=https://cdn.jsdelivr.net/npm/bootstrap@5.3.7/dist/js/bootstrap.
CHOICES_CSS_URL=https://cdn.jsdelivr.net/npm/choices.js@11.1.0/public/assets/styles/choices.min.css CHOICES_CSS_URL=https://cdn.jsdelivr.net/npm/choices.js@11.1.0/public/assets/styles/choices.min.css
CHOICES_JS_URL=https://cdn.jsdelivr.net/npm/choices.js@11.1.0/public/assets/scripts/choices.min.js CHOICES_JS_URL=https://cdn.jsdelivr.net/npm/choices.js@11.1.0/public/assets/scripts/choices.min.js
CHART_JS_URL=https://cdn.jsdelivr.net/npm/chart.js@4.5.0/dist/chart.umd.min.js CHART_JS_URL=https://cdn.jsdelivr.net/npm/chart.js@4.5.0/dist/chart.umd.min.js
SOCKET_IO_JS_URL=https://cdn.socket.io/4.8.1/socket.io.min.js
THEME_BOOTSTRAP_URL=https://cdn.jsdelivr.net/npm/bootstrap@5.3.7/dist/css/bootstrap.min.css THEME_BOOTSTRAP_URL=https://cdn.jsdelivr.net/npm/bootstrap@5.3.7/dist/css/bootstrap.min.css
THEME_FLATLY_URL=https://cdn.jsdelivr.net/npm/bootswatch@5.3.7/dist/flatly/bootstrap.min.css THEME_FLATLY_URL=https://cdn.jsdelivr.net/npm/bootswatch@5.3.7/dist/flatly/bootstrap.min.css
THEME_DARKLY_URL=https://cdn.jsdelivr.net/npm/bootswatch@5.3.7/dist/darkly/bootstrap.min.css THEME_DARKLY_URL=https://cdn.jsdelivr.net/npm/bootswatch@5.3.7/dist/darkly/bootstrap.min.css
+1 -1
View File
@@ -15,5 +15,5 @@ RUN mkdir -p /data
HEALTHCHECK --interval=30s --timeout=6s --retries=3 \ HEALTHCHECK --interval=30s --timeout=6s --retries=3 \
CMD python -c "import os, urllib.request; urllib.request.urlopen('http://127.0.0.1:' + os.getenv('APP_PORT', '8000') + '/health', timeout=5)" CMD python -c "import os, urllib.request; urllib.request.urlopen('http://127.0.0.1:' + os.getenv('APP_PORT', '8000') + '/health', timeout=5)"
CMD ["sh", "-c", "gunicorn --worker-class gthread --workers ${GUNICORN_WORKERS:-1} --threads ${GUNICORN_THREADS:-8} --timeout ${GUNICORN_TIMEOUT:-0} --bind 0.0.0.0:${APP_PORT:-8000} wsgi:app"] CMD ["sh", "-c", "gunicorn --worker-class gthread --workers ${GUNICORN_WORKERS:-1} --threads ${GUNICORN_THREADS:-8} --timeout ${GUNICORN_TIMEOUT:-60} --bind 0.0.0.0:${APP_PORT:-8000} wsgi:app"]
-9
View File
@@ -17,15 +17,6 @@ Pierwsze konto:
Zmień hasło i `SECRET_KEY` przed wdrożeniem. Zmień hasło i `SECRET_KEY` przed wdrożeniem.
## WebSocket i Gunicorn
Projekt uruchamia jeden worker `gthread`, 8 wątków i wyłączony timeout dla długich połączeń Socket.IO:
```bash
gunicorn --worker-class gthread --workers 1 --threads 8 --timeout 0 --bind 0.0.0.0:8000 wsgi:app
```
Nie uruchamiaj aplikacji przez samo `gunicorn wsgi:app`, ponieważ domyślny worker `sync` i timeout 30 sekund mogą przerywać WebSocket błędem `SystemExit: 1`.
## Ceny Orlen ## Ceny Orlen
+75 -16
View File
@@ -2,27 +2,59 @@ from flask import Flask, request, url_for
import click import click
import hashlib import hashlib
from pathlib import Path from pathlib import Path
from .extensions import db, login_manager, socketio from .extensions import db, login_manager
from .models import User, AppSetting from .models import User, AppSetting
from .config import Config from .config import Config
THEMES = {name: {} for name in ("bootstrap", "flatly", "darkly", "pulse")} THEMES = {name: {} for name in ("bootstrap", "flatly", "darkly", "pulse")}
class RemoveVaryCookieMiddleware:
def __init__(self, app):
self.app = app
def __call__(self, environ, start_response):
path = environ.get("PATH_INFO", "")
def custom_start_response(status, headers, exc_info=None):
if path == "/favicon.ico" or path.endswith((".css", ".js")):
result, vary_values = [], []
for name, value in headers:
if name.lower() == "vary":
vary_values.extend(item.strip() for item in value.split(",") if item.strip())
else:
result.append((name, value))
vary_values = [value for value in vary_values if value.lower() != "cookie"]
if vary_values:
result.append(("Vary", ", ".join(dict.fromkeys(vary_values))))
headers = result
return start_response(status, headers, exc_info)
return self.app(environ, custom_start_response)
def _static_hashes(app): def _static_hashes(app):
static_root = Path(app.static_folder) static_root = Path(app.static_folder)
hashes = {} hashes = {}
for path in static_root.rglob("*"): for path in static_root.rglob("*"):
if path.is_file(): if path.is_file():
relative = path.relative_to(static_root).as_posix() relative = path.relative_to(static_root).as_posix()
hashes[relative] = hashlib.md5(path.read_bytes()).hexdigest() hashes[relative] = hashlib.md5(path.read_bytes()).hexdigest()
return hashes return hashes
def _themes(app): def _themes(app):
return { return {
"bootstrap": {"label": "Bootstrap", "url": app.config["THEME_BOOTSTRAP_URL"], "mode": "light"}, "bootstrap": {"label": "Bootstrap", "url": app.config["THEME_BOOTSTRAP_URL"], "mode": "light"},
"flatly": {"label": "Flatly", "url": app.config["THEME_FLATLY_URL"], "mode": "light"}, "flatly": {"label": "Flatly", "url": app.config["THEME_FLATLY_URL"], "mode": "light"},
"darkly": {"label":"Darkly", "url":app.config["THEME_DARKLY_URL"], "mode":"dark"}, "darkly": {"label": "Ciemny", "url": app.config["THEME_BOOTSTRAP_URL"], "mode": "dark"},
"pulse": {"label": "Pulse", "url": app.config["THEME_PULSE_URL"], "mode": "light"}, "pulse": {"label": "Pulse", "url": app.config["THEME_PULSE_URL"], "mode": "light"},
} }
@@ -31,14 +63,19 @@ def create_app(test_config=None):
app = Flask(__name__) app = Flask(__name__)
app.config.from_object(Config) app.config.from_object(Config)
if test_config: app.config.update(test_config) if test_config: app.config.update(test_config)
db.init_app(app); login_manager.init_app(app); socketio.init_app(app)
db.init_app(app); login_manager.init_app(app)
from .database import prepare_database from .database import prepare_database
prepare_database(app) prepare_database(app)
from .auth import auth; from .main import main
from .auth import auth
from .main import main
from .api import api from .api import api
from .openapi import docs from .openapi import docs
app.register_blueprint(auth); app.register_blueprint(main); app.register_blueprint(api); app.register_blueprint(docs)
app.register_blueprint(auth); app.register_blueprint(main)
app.register_blueprint(api); app.register_blueprint(docs)
static_hashes = _static_hashes(app) static_hashes = _static_hashes(app)
@@ -50,14 +87,21 @@ def create_app(test_config=None):
@app.after_request @app.after_request
def set_cache_headers(response): def set_cache_headers(response):
if request.path == "/favicon.ico":
response.headers.pop("Cache-Control", None)
response.headers.pop("Content-Disposition", None)
return response
if request.endpoint == "static": if request.endpoint == "static":
filename = request.view_args.get("filename", "") if request.view_args else "" filename = request.view_args.get("filename", "") if request.view_args else ""
if filename.endswith(".css"):
response.headers["Cache-Control"] = "public, max-age=2592000, immutable" if filename.endswith((".css", ".js")):
else:
response.headers["Cache-Control"] = "public, max-age=2592000, immutable" response.headers["Cache-Control"] = "public, max-age=2592000, immutable"
response.headers.pop("Content-Disposition", None)
elif response.mimetype == "text/html": elif response.mimetype == "text/html":
response.headers["Cache-Control"] = "no-store, no-cache, private, must-revalidate" response.headers["Cache-Control"] = "no-store, private, must-revalidate"
return response return response
@login_manager.user_loader @login_manager.user_loader
@@ -67,8 +111,8 @@ def create_app(test_config=None):
def load_user_from_request(req): def load_user_from_request(req):
header = req.headers.get("Authorization", "") header = req.headers.get("Authorization", "")
scheme, _, token = header.partition(" ") scheme, _, token = header.partition(" ")
if scheme.lower() != "bearer" or not token.strip(): if scheme.lower() != "bearer" or not token.strip(): return None
return None
from .api_tokens import load_access_token from .api_tokens import load_access_token
return load_access_token(token.strip()) return load_access_token(token.strip())
@@ -76,38 +120,53 @@ def create_app(test_config=None):
def unauthorized(): def unauthorized():
if request.path.startswith("/api/"): if request.path.startswith("/api/"):
return {"ok": False, "message": "Wymagane uwierzytelnienie Bearer lub aktywna sesja"}, 401 return {"ok": False, "message": "Wymagane uwierzytelnienie Bearer lub aktywna sesja"}, 401
from flask import redirect from flask import redirect
return redirect(url_for("auth.login", next=request.url)) return redirect(url_for("auth.login", next=request.full_path.rstrip("?")))
@app.context_processor @app.context_processor
def inject_theme(): def inject_theme():
themes = _themes(app) themes = _themes(app)
record = db.session.get(AppSetting, "theme") record = db.session.get(AppSetting, "theme")
theme_name = record.value if record and record.value in themes else "bootstrap" theme_name = record.value if record and record.value in themes else "bootstrap"
return {"app_theme": themes[theme_name], "app_theme_name": theme_name, "themes": themes,
"asset_urls": {"bootstrap_js": app.config["BOOTSTRAP_JS_URL"], "choices_css": app.config["CHOICES_CSS_URL"], return {
"choices_js": app.config["CHOICES_JS_URL"], "chart_js": app.config["CHART_JS_URL"], "socket_io_js": app.config["SOCKET_IO_JS_URL"]}} "app_theme": themes[theme_name],
"app_theme_name": theme_name,
"themes": themes,
"asset_urls": {
"bootstrap_js": app.config["BOOTSTRAP_JS_URL"],
"choices_css": app.config["CHOICES_CSS_URL"],
"choices_js": app.config["CHOICES_JS_URL"],
"chart_js": app.config["CHART_JS_URL"],
},
}
@app.get("/health") @app.get("/health")
def health(): return {"status": "ok"} def health(): return {"status": "ok"}
@app.get("/favicon.ico")
def favicon(): return app.response_class(status=204)
@app.cli.command("reset-admin-password") @app.cli.command("reset-admin-password")
@click.option("--email", required=True, help="Adres e-mail konta administratora.") @click.option("--email", required=True, help="Adres e-mail konta administratora.")
@click.option("--password", prompt=True, hide_input=True, confirmation_prompt=True, @click.option("--password", prompt=True, hide_input=True, confirmation_prompt=True,
help="Nowe hasło. Bez tej opcji polecenie poprosi o hasło bez wyświetlania go.") help="Nowe hasło. Bez tej opcji polecenie poprosi o hasło bez wyświetlania go.")
def reset_admin_password(email, password): def reset_admin_password(email, password):
"""Resetuje hasło istniejącego administratora."""
normalized_email = email.strip().lower() normalized_email = email.strip().lower()
user = User.query.filter_by(email=normalized_email).first() user = User.query.filter_by(email=normalized_email).first()
if not user: if not user:
raise click.ClickException("Nie znaleziono użytkownika o podanym adresie e-mail.") raise click.ClickException("Nie znaleziono użytkownika o podanym adresie e-mail.")
if user.role != "admin": if user.role != "admin":
raise click.ClickException("Wskazany użytkownik nie ma roli administratora.") raise click.ClickException("Wskazany użytkownik nie ma roli administratora.")
if len(password) < 8: if len(password) < 8:
raise click.ClickException("Hasło musi mieć co najmniej 8 znaków.") raise click.ClickException("Hasło musi mieć co najmniej 8 znaków.")
user.set_password(password) user.set_password(password)
user.active = True user.active = True
db.session.commit() db.session.commit()
click.echo(f"Zresetowano hasło administratora: {normalized_email}") click.echo(f"Zresetowano hasło administratora: {normalized_email}")
app.wsgi_app = RemoveVaryCookieMiddleware(app.wsgi_app)
return app return app
-523
View File
@@ -1,523 +0,0 @@
from collections import defaultdict
from datetime import datetime, date
from decimal import Decimal
from functools import wraps
from flask import Blueprint, jsonify, request, url_for, current_app
from flask_login import current_user, login_required, login_user, logout_user
from sqlalchemy import extract
from sqlalchemy.exc import IntegrityError
from .extensions import db, socketio
from .models import User, Vehicle, FuelEntry, CompanySettings, AppSetting, OrlenPrice, FuelCardPolicy, FuelStationCompany, FuelStationPoint, FuelCard, FuelCardStationRule
from .services import calculate_costs, fetch_orlen_price, fetch_orlen_range, invoice_period, fetch_ure_stations, aggregate_ure_companies, POLISH_REGIONS
from .station_catalog import sync_station_catalog
from . import THEMES
from .api_tokens import create_access_token
api = Blueprint('api', __name__, url_prefix='/api')
FUEL_TYPES = ('PB95','PB98','DIESEL','LPG')
def response(data=None, message=None, status=200):
payload={'ok': status < 400}
if message: payload['message']=message
if data is not None: payload['data']=data
return jsonify(payload), status
def fail(message, status=400): return response(message=message,status=status)
def role_required(*allowed):
def deco(fn):
@wraps(fn)
@login_required
def wrapped(*a,**kw):
if current_user.role not in allowed: return fail('Brak uprawnień',403)
return fn(*a,**kw)
return wrapped
return deco
def payload():
return request.get_json(silent=True) or request.form
def boolv(data,key):
v=data.get(key)
return v in (True,1,'1','true','on','yes')
def accessible_vehicles():
if current_user.role=='admin': return Vehicle.query.order_by(Vehicle.name).all()
ids={v.id for v in current_user.owned_vehicles+current_user.shared_vehicles}
return Vehicle.query.filter(Vehicle.id.in_(ids)).order_by(Vehicle.name).all() if ids else []
def user_dict(u): return {'id':u.id,'name':u.name,'email':u.email,'role':u.role,'active':u.active,'fuel_card_id':u.fuel_card_id,'fuel_card':u.fuel_card.name if u.fuel_card else None,'company_id':u.company_id,'company':u.company.name if u.company else None}
def vehicle_dict(v): return {'id':v.id,'name':v.name,'make':v.make,'model':v.model,'registration':v.registration,'fuel_type':v.fuel_type,'owner':user_dict(v.owner),'drivers':[user_dict(x) for x in v.drivers],'has_fuel_card':bool(v.fuel_card_id),'fuel_card_id':v.fuel_card_id,'fuel_card':v.fuel_card.name if v.fuel_card else None,'current_odometer':v.current_odometer}
def station_dict(s):
settings=CompanySettings.query.first()
return {'id':s.id,'brand_name':s.display_name,'company_name':s.company_name,'nip':s.nip,'regon':s.regon,'brands':s.station_name_list,'regions':s.region_list,'selected_region':s.selected_region,'station_count':s.station_count,'has_petrol':s.has_petrol,'has_diesel':s.has_diesel,'has_lpg':s.has_lpg,'use_orlen_last_price':s.use_orlen_last_price,'active':s.active,'favorite':s in current_user.favorite_stations if current_user.is_authenticated else False,'allowed':s in settings.allowed_stations if settings else True}
@api.post('/auth/login')
def login():
d=payload(); user=User.query.filter_by(email=(d.get('email') or '').strip().lower()).first()
if not user or not user.check_password(d.get('password') or '') or not user.active: return fail('Nieprawidłowy e-mail lub hasło',401)
login_user(user); return response({'user':user_dict(user),'redirect':url_for('main.dashboard')},'Zalogowano')
@api.post('/auth/token')
def token_login():
d = payload()
email = (d.get('email') or d.get('username') or '').strip().lower()
user = User.query.filter_by(email=email).first()
if not user or not user.check_password(d.get('password') or '') or not user.active:
return jsonify({'error':'invalid_grant','error_description':'Nieprawidłowy e-mail lub hasło'}), 401
expires_in = int(current_app.config['API_TOKEN_MAX_AGE_SECONDS'])
return jsonify({
'access_token': create_access_token(user),
'token_type': 'Bearer',
'expires_in': expires_in,
'user': user_dict(user),
})
@api.post('/auth/logout')
@login_required
def logout(): logout_user(); return response({'redirect':url_for('auth.login')},'Wylogowano')
@api.get('/me')
@login_required
def me(): return response(user_dict(current_user))
@api.get('/vehicles')
@login_required
def get_vehicles(): return response([vehicle_dict(v) for v in accessible_vehicles()])
@api.post('/vehicles')
@login_required
def create_vehicle():
d=payload()
try:
card_id=int(d['fuel_card_id']) if d.get('fuel_card_id') else None
owner_id=int(d.get('owner_id') or current_user.id);owner=db.session.get(User,owner_id)
if not owner: return fail('Nie znaleziono właściciela',404)
if current_user.role not in ('boss','admin') and owner.id!=current_user.id:return fail('Brak uprawnień',403)
if current_user.role=='boss' and owner.company_id!=current_user.company_id:return fail('Użytkownik jest w innej firmie',403)
v=Vehicle(name=d['name'].strip(),make=d['make'].strip(),model=d['model'].strip(),registration=d['registration'].strip().upper(),fuel_type=d['fuel_type'],owner_id=owner.id,company_id=owner.company_id,has_fuel_card=bool(card_id),fuel_card_id=card_id,current_odometer=int(d.get('current_odometer') or 0))
db.session.add(v); db.session.commit(); socketio.emit('app_changed',{'resource':'vehicle','id':v.id}); return response(vehicle_dict(v),'Dodano pojazd',201)
except (KeyError,ValueError,IntegrityError) as exc: db.session.rollback(); return fail(str(getattr(exc,'orig',exc)),400)
@api.put('/vehicles/<int:vehicle_id>/fuel-card')
@login_required
def assign_vehicle_fuel_card(vehicle_id):
v=Vehicle.query.get_or_404(vehicle_id)
if current_user.role not in ('boss','admin') and v.owner_id!=current_user.id:return fail('Brak uprawnień',403)
d=payload();card_id=int(d['fuel_card_id']) if d.get('fuel_card_id') else None
card=db.session.get(FuelCard,card_id) if card_id else None
if card_id and not card:return fail('Nie znaleziono karty paliwowej',404)
if card and v.company_id and card.company_id!=v.company_id:return fail('Karta należy do innej firmy',400)
v.fuel_card_id=card_id;v.has_fuel_card=bool(card_id);db.session.commit();socketio.emit('app_changed',{'resource':'vehicle','id':v.id});return response(vehicle_dict(v),'Zapisano kartę pojazdu')
@api.post('/vehicles/<int:vehicle_id>/shares')
@login_required
def share_vehicle(vehicle_id):
v=Vehicle.query.get_or_404(vehicle_id)
if current_user.role!='admin' and v.owner_id!=current_user.id: return fail('Brak uprawnień',403)
d=payload(); u=User.query.filter_by(email=(d.get('email') or '').strip().lower()).first()
if not u:return fail('Nie znaleziono użytkownika',404)
if u not in v.drivers:v.drivers.append(u);db.session.commit()
socketio.emit('app_changed',{'resource':'vehicle_share','id':v.id});return response(vehicle_dict(v),'Udostępniono pojazd')
@api.delete('/vehicles/<int:vehicle_id>/shares/<int:user_id>')
@login_required
def unshare_vehicle(vehicle_id,user_id):
v=Vehicle.query.get_or_404(vehicle_id)
if current_user.role!='admin' and v.owner_id!=current_user.id:return fail('Brak uprawnień',403)
u=User.query.get_or_404(user_id)
if u in v.drivers:v.drivers.remove(u);db.session.commit()
socketio.emit('app_changed',{'resource':'vehicle_share','id':v.id});return response(vehicle_dict(v),'Odebrano dostęp')
@api.put('/vehicles/<int:vehicle_id>/card-policy')
@login_required
def card_policy(vehicle_id):
v=Vehicle.query.get_or_404(vehicle_id)
if current_user.role!='admin' and v.owner_id!=current_user.id:return fail('Brak uprawnień',403)
d=payload();p=v.fuel_card_policy or FuelCardPolicy(vehicle=v)
p.use_orlen_last_price=False;p.discount_percent=Decimal(d.get('discount_percent') or 0);p.surcharge_per_liter=Decimal(d.get('surcharge_per_liter') or 0);p.description=(d.get('description') or '').strip() or None
db.session.add(p);db.session.commit();socketio.emit('app_changed',{'resource':'card_policy','id':v.id});return response(message='Zapisano zasady rozliczeń')
@api.post('/fuel-entries')
@login_required
def create_fuel_entry():
d=payload(); vehicles=accessible_vehicles()
try: vehicle=next((v for v in vehicles if v.id==int(d['vehicle_id'])),None)
except Exception: vehicle=None
if not vehicle:return fail('Brak dostępu do pojazdu',403)
try:
odo=int(d['odometer']); fueled_at=datetime.fromisoformat(d['fueled_at']); station_company=db.session.get(FuelStationCompany,int(d['station_company_id'])) if d.get('station_company_id') else None
settings=vehicle.company or current_user.company or CompanySettings.query.first()
access_mode=settings.station_access_mode or 'all_prefer_favorites'
if station_company and access_mode=='allowed_only' and station_company not in settings.allowed_stations:
return fail('Ta stacja nie jest dozwolona przez politykę firmy',403)
if station_company and access_mode=='favorites_allowed_only' and station_company not in settings.allowed_stations and station_company not in settings.favorite_stations:
return fail('Ta stacja nie jest ulubiona ani dozwolona przez politykę firmy',403)
wholesale=source=None; region=(station_company.selected_region if station_company else None) or settings.region
try:
p=fetch_orlen_price(d['fuel_type'],fueled_at.date(),region);wholesale=p['price_per_liter'];source=p['source']
except Exception: pass
card_id=int(d['fuel_card_id']) if d.get('fuel_card_id') else (vehicle.fuel_card_id or current_user.fuel_card_id)
card=db.session.get(FuelCard,card_id) if card_id else None
used_card=boolv(d,'used_fuel_card') and bool(card)
if used_card and station_company:
rule=FuelCardStationRule.query.filter_by(fuel_card_id=card.id,station_company_id=station_company.id).first()
if rule and not rule.allowed:return fail('Ta stacja jest zablokowana dla wybranej karty paliwowej',403)
e=FuelEntry(vehicle_id=vehicle.id,user_id=current_user.id,fueled_at=fueled_at,liters=Decimal(d['liters']),price_per_liter=Decimal(d['price_per_liter']),fuel_type=d['fuel_type'],odometer=odo,station=station_company.company_name if station_company else d.get('station'),station_company_id=station_company.id if station_company else None,fuel_card_id=card.id if used_card else None,invoice_number=d.get('invoice_number'),used_fuel_card=used_card,wholesale_price=wholesale,wholesale_source=source)
vehicle.current_odometer=odo;db.session.add(e);db.session.commit();socketio.emit('fuel_added',{'vehicle':vehicle.name,'gross':round(e.gross,2)});return response({'id':e.id,'redirect':url_for('main.dashboard')},'Zapisano tankowanie',201)
except Exception as exc:db.session.rollback();return fail(str(exc),400)
@api.get('/orlen/prices')
@login_required
def orlen_prices():
year=request.args.get('year',type=int) or date.today().year; fuels=[x.upper() for x in request.args.getlist('fuel') if x.upper() in FUEL_TYPES] or ['PB95']; regions=[x.lower() for x in request.args.getlist('region') if x.lower() in POLISH_REGIONS]
rows=OrlenPrice.query.filter(OrlenPrice.fuel_type.in_(fuels),extract('year',OrlenPrice.effective_date)==year).order_by(OrlenPrice.effective_date).all()
return response([{'fuel_type':r.fuel_type,'date':r.effective_date.isoformat(),'price_per_liter':float(r.price_per_liter),'region':r.region,'source':r.source} for r in rows if r.fuel_type!='LPG' or not regions or r.region in regions])
@api.post('/orlen/sync')
@role_required('boss','admin')
def orlen_sync():
d=payload(); fuels=d.get('fuels',[]); fuels=[fuels] if isinstance(fuels,str) else fuels; fuels=[f.upper() for f in fuels if f.upper() in FUEL_TYPES]; year=int(d.get('year',date.today().year))
regions=d.get('regions',[]); regions=[regions] if isinstance(regions,str) else regions; regions=[str(r).strip().lower() for r in regions if str(r).strip().lower() in POLISH_REGIONS]
if not fuels:return fail('Wybierz co najmniej jedno paliwo')
settings=CompanySettings.query.first(); result={}; total=0
try:
for fuel in fuels:
added=updated=0
for row in fetch_orlen_range(fuel,date(year,1,1),min(date(year,12,31),date.today()),'all' if fuel=='LPG' else settings.region):
if fuel=='LPG' and regions and row.get('region') not in regions: continue
x=OrlenPrice.query.filter_by(fuel_type=row['fuel_type'],effective_date=row['effective_date'],region=row['region']).first()
if x:
for k,v in row.items():setattr(x,k,v)
x.fetched_at=datetime.utcnow();updated+=1
else:db.session.add(OrlenPrice(**row));added+=1
result[fuel]={'added':added,'updated':updated};total+=added+updated
db.session.commit();socketio.emit('app_changed',{'resource':'orlen'});return response(result,f'Przetworzono {total} rekordów')
except Exception as exc:db.session.rollback();return fail(str(exc),502)
@api.get('/orlen/preview')
@login_required
def orlen_preview():
try:return response(fetch_orlen_price(request.args['fuel_type'],datetime.fromisoformat(request.args['date']).date(),request.args.get('region') or CompanySettings.query.first().region))
except Exception as exc:return fail(str(exc),502)
@api.get('/users')
@role_required('admin')
def users():
page=request.args.get('page',1,type=int);per=min(request.args.get('per_page',25,type=int),100);q=request.args.get('q','').strip();query=User.query
if q: query=query.filter(db.or_(User.name.ilike(f'%{q}%'),User.email.ilike(f'%{q}%')))
p=query.order_by(User.name).paginate(page=page,per_page=per,error_out=False);return response({'items':[user_dict(x) for x in p.items],'page':p.page,'pages':p.pages,'total':p.total})
@api.post('/users')
@role_required('boss','admin')
def create_user():
d=payload()
try:
if len(d.get('password',''))<8:raise ValueError('Hasło musi mieć co najmniej 8 znaków')
company_id=int(d.get('company_id') or current_user.company_id or CompanySettings.query.first().id)
if current_user.role=='boss' and company_id!=current_user.company_id:return fail('Brak uprawnień',403)
u=User(name=d['user_name'].strip(),email=d['email'].strip().lower(),role=d.get('role','user'),company_id=company_id);u.set_password(d['password']);db.session.add(u);db.session.commit();return response(user_dict(u),'Dodano użytkownika',201)
except Exception as exc:db.session.rollback();return fail(str(getattr(exc,'orig',exc)))
@api.put('/users/<int:user_id>')
@role_required('admin')
def update_user(user_id):
u=User.query.get_or_404(user_id);d=payload()
try:
u.name=d['name'].strip();u.email=d['email'].strip().lower();u.role=d['role'];u.active=boolv(d,'active');u.company_id=int(d.get('company_id') or u.company_id or CompanySettings.query.first().id);u.fuel_card_id=int(d['fuel_card_id']) if d.get('fuel_card_id') else None;pw=d.get('password','')
if pw:
if len(pw)<8:raise ValueError('Nowe hasło musi mieć co najmniej 8 znaków')
u.set_password(pw)
db.session.commit();return response(user_dict(u),'Zaktualizowano użytkownika')
except Exception as exc:db.session.rollback();return fail(str(getattr(exc,'orig',exc)))
@api.delete('/users/<int:user_id>')
@role_required('admin')
def delete_user(user_id):
user = User.query.get_or_404(user_id)
if user.id == current_user.id:
return fail('Nie możesz usunąć własnego konta administratora', 400)
if user.role == 'admin':
remaining_active_admins = User.query.filter(
User.role == 'admin', User.active.is_(True), User.id != user.id
).count()
if remaining_active_admins < 1:
return fail('Nie można usunąć ostatniego aktywnego administratora', 409)
if user.owned_vehicles:
return fail('Najpierw przypisz pojazdy tego użytkownika innemu właścicielowi', 409)
if FuelEntry.query.filter_by(user_id=user.id).first():
return fail('Użytkownik ma historię tankowań i nie może zostać usunięty. Możesz go dezaktywować.', 409)
user.shared_vehicles.clear()
user.favorite_stations.clear()
db.session.delete(user)
db.session.commit()
socketio.emit('app_changed', {'resource':'users','deleted_id':user_id})
return response(message='Usunięto użytkownika')
@api.get('/settings')
@role_required('boss','admin')
def get_settings():
s=CompanySettings.query.first();return response({'name':s.name,'entity_type':s.entity_type,'vat_rate':float(s.vat_rate),'vat_deduction_percent':float(s.vat_deduction_percent),'region':s.region,'invoice_split_day':s.invoice_split_day,'invoice_split_enabled':(db.session.get(AppSetting,'invoice_split_enabled').value=='1')})
@api.put('/settings')
@role_required('boss','admin')
def update_settings():
d=payload();s=CompanySettings.query.first()
try:
s.name=d['name'];s.entity_type=d['entity_type'];s.vat_rate=Decimal(d['vat_rate']);s.vat_deduction_percent=Decimal(d['vat_deduction_percent']);s.region=d['region'].strip().lower();s.invoice_split_day=int(d.get('invoice_split_day') or 15)
db.session.merge(AppSetting(key='invoice_split_enabled',value='1' if boolv(d,'invoice_split_enabled') else '0'));db.session.commit();socketio.emit('app_changed',{'resource':'settings'});return response(message='Zapisano ustawienia')
except Exception as exc:db.session.rollback();return fail(str(exc))
@api.get('/app-settings')
@role_required('boss','admin')
def get_app_settings():
return response({'theme':(db.session.get(AppSetting,'theme') or AppSetting(value='bootstrap')).value})
@api.put('/app-settings')
@role_required('boss','admin')
def update_app_settings():
d=payload();theme=d.get('theme','bootstrap')
if theme not in THEMES:return fail('Nieprawidłowy motyw')
db.session.merge(AppSetting(key='theme',value=theme));db.session.commit();socketio.emit('app_changed',{'resource':'app_settings'});return response(message='Zapisano ustawienia aplikacji')
@api.get('/fuel-cards')
@login_required
def get_fuel_cards():
rows=FuelCard.query.order_by(FuelCard.name).all()
return response([{'id':x.id,'name':x.name,'provider':x.provider,'active':x.active,'description':x.description} for x in rows])
@api.post('/fuel-cards')
@role_required('boss','admin')
def create_fuel_card():
d=payload()
try:
company_id=int(d.get('company_id') or current_user.company_id or CompanySettings.query.first().id)
if current_user.role=='boss' and company_id!=current_user.company_id:return fail('Brak uprawnień',403)
card=FuelCard(name=d['name'].strip(),provider=d['provider'].strip(),description=(d.get('description') or '').strip() or None,active=True,company_id=company_id)
db.session.add(card);db.session.commit();socketio.emit('app_changed',{'resource':'fuel_cards'});return response({'id':card.id},'Dodano kartę paliwową',201)
except Exception as exc:db.session.rollback();return fail(str(getattr(exc,'orig',exc)))
@api.put('/fuel-cards/<int:card_id>')
@role_required('boss','admin')
def update_fuel_card(card_id):
card=FuelCard.query.get_or_404(card_id);d=payload();card.name=d.get('name',card.name).strip();card.provider=d.get('provider',card.provider).strip();card.description=(d.get('description') or '').strip() or None;card.active=boolv(d,'active');db.session.commit();return response(message='Zapisano kartę')
@api.delete('/fuel-cards/<int:card_id>')
@role_required('boss','admin')
def delete_fuel_card(card_id):
card = FuelCard.query.get_or_404(card_id)
if current_user.role == 'boss' and card.company_id != current_user.company_id:
return fail('Brak uprawnień', 403)
users_count = User.query.filter_by(fuel_card_id=card.id).count()
vehicles_count = Vehicle.query.filter_by(fuel_card_id=card.id).count()
entries_count = FuelEntry.query.filter_by(fuel_card_id=card.id).count()
blockers = []
if users_count:
blockers.append(f'{users_count} użytkowników')
if vehicles_count:
blockers.append(f'{vehicles_count} pojazdów')
if entries_count:
blockers.append(f'{entries_count} tankowań')
if blockers:
return fail(
'Nie można usunąć karty, ponieważ jest przypisana do: ' + ', '.join(blockers) +
'. Najpierw odłącz kartę od użytkowników i pojazdów. Karty użytej w tankowaniach nie można usunąć; możesz ją wyłączyć.',
409,
)
company_id = card.company_id
db.session.delete(card)
db.session.commit()
socketio.emit('app_changed', {'resource':'fuel_cards', 'deleted_id':card_id})
return response(
{'redirect':url_for('main.admin_companies', company_id=company_id)},
'Usunięto kartę flotową',
)
@api.put('/fuel-cards/<int:card_id>/stations/<int:station_id>')
@role_required('boss','admin')
def update_card_station_rule(card_id,station_id):
FuelCard.query.get_or_404(card_id);FuelStationCompany.query.get_or_404(station_id);d=payload()
rule=FuelCardStationRule.query.filter_by(fuel_card_id=card_id,station_company_id=station_id).first() or FuelCardStationRule(fuel_card_id=card_id,station_company_id=station_id)
rule.allowed=boolv(d,'allowed');rule.use_orlen_last_price=boolv(d,'use_orlen_last_price');rule.discount_net_percent=Decimal(d.get('discount_net_percent') or 0);rule.surcharge_net_per_liter=Decimal(d.get('surcharge_net_per_liter') or 0)
db.session.add(rule);db.session.commit();socketio.emit('app_changed',{'resource':'card_station_rule'});return response(message='Zapisano warunki karty dla stacji')
@api.get('/stations')
@login_required
def stations():
q=request.args.get('q','').strip();page=request.args.get('page',1,type=int);per=min(request.args.get('per_page',30,type=int),100);query=FuelStationCompany.query.filter_by(active=True)
if q:
for term in [x for x in q.split() if x]:
needle=f'%{term}%'
query=query.filter(db.or_(FuelStationCompany.brand_name.ilike(needle),FuelStationCompany.company_name.ilike(needle),FuelStationCompany.nip.ilike(needle),FuelStationCompany.regon.ilike(needle),FuelStationCompany.station_names.ilike(needle)))
sort=request.args.get('sort','company_name');direction=request.args.get('direction','asc')
allowed={'company_name':FuelStationCompany.brand_name,'station_count':FuelStationCompany.station_count,'nip':FuelStationCompany.nip,'regon':FuelStationCompany.regon,'selected_region':FuelStationCompany.selected_region}
col=allowed.get(sort,FuelStationCompany.company_name);query=query.order_by(col.desc() if direction=='desc' else col.asc(),FuelStationCompany.brand_name.asc())
p=query.paginate(page=page,per_page=per,error_out=False);return response({'items':[station_dict(x) for x in p.items],'page':p.page,'pages':p.pages,'total':p.total,'sort':sort,'direction':direction})
@api.get('/stations/<int:station_id>/points')
@login_required
def station_points(station_id):
station=FuelStationCompany.query.get_or_404(station_id)
page=max(request.args.get('page',1,type=int),1)
per_page=min(max(request.args.get('per_page',50,type=int),1),100)
q=(request.args.get('q') or '').strip()
query=FuelStationPoint.query.filter_by(station_company_id=station.id)
if q:
needle=f'%{q}%'
query=query.filter(db.or_(FuelStationPoint.station_name.ilike(needle),FuelStationPoint.street.ilike(needle),FuelStationPoint.city.ilike(needle),FuelStationPoint.postal_code.ilike(needle)))
query=query.order_by(FuelStationPoint.city.asc(),FuelStationPoint.street.asc(),FuelStationPoint.street_number.asc())
result=query.paginate(page=page,per_page=per_page,error_out=False)
items=[{'id':x.id,'dkn':x.ure_dkn.split(':',1)[0] if x.ure_dkn else '','name':x.station_name,'address':x.address,'city':x.city,'region':x.region,'coordinates':x.coordinates,'has_petrol':x.has_petrol,'has_diesel':x.has_diesel,'has_lpg':x.has_lpg} for x in result.items]
return response({'station':station_dict(station),'items':items,'page':result.page,'pages':result.pages,'total':result.total})
@api.put('/me/favorite-stations')
@login_required
def favorite_stations():
d=payload();ids=request.form.getlist('station_ids') if request.form else d.get('station_ids',[]);ids=[ids] if isinstance(ids,(str,int)) else ids
try: ids=[int(x) for x in ids]
except Exception: return fail('Nieprawidłowa lista stacji')
if len(ids)>10:return fail('Użytkownik może mieć maksymalnie 10 ulubionych stacji')
current_user.favorite_stations=FuelStationCompany.query.filter(FuelStationCompany.id.in_(ids),FuelStationCompany.active.is_(True)).all() if ids else []
db.session.commit();socketio.emit('app_changed',{'resource':'favorite_stations','user_id':current_user.id});return response([station_dict(x) for x in current_user.favorite_stations],'Zapisano ulubione stacje')
@api.put('/settings/allowed-stations')
@role_required('boss','admin')
def allowed_stations():
d=payload();ids=request.form.getlist('station_ids') if request.form else d.get('station_ids',[]);ids=[ids] if isinstance(ids,(str,int)) else ids
try: ids=[int(x) for x in ids]
except Exception: return fail('Nieprawidłowa lista stacji')
settings=CompanySettings.query.first();settings.allowed_stations=FuelStationCompany.query.filter(FuelStationCompany.id.in_(ids),FuelStationCompany.active.is_(True)).all() if ids else []
db.session.commit();socketio.emit('app_changed',{'resource':'allowed_stations'});return response([station_dict(x) for x in settings.allowed_stations],'Zapisano dozwolone stacje')
@api.post('/stations/sync')
@role_required('boss','admin')
def stations_sync():
try:
companies=aggregate_ure_companies(fetch_ure_stations())
added,updated=sync_station_catalog(companies)
db.session.commit();socketio.emit('app_changed',{'resource':'stations'});return response({'added':added,'updated':updated},f'Katalog URE: dodano {added}, zaktualizowano {updated}')
except Exception as exc:db.session.rollback();return fail(f'Nie udało się pobrać danych URE: {exc}',502)
@api.put('/stations/<int:station_id>')
@role_required('boss','admin')
def update_station(station_id):
s=FuelStationCompany.query.get_or_404(station_id);d=payload()
region=None
if 'selected_region' in d:
region=(d.get('selected_region') or '').strip().lower()
if region and region not in s.region_list:return fail('Wybrane województwo nie występuje w danych tej firmy')
try:
company_id=int(d.get('company_id') or current_user.company_id or 0)
except (TypeError,ValueError):
return fail('Nieprawidłowa firma')
settings=CompanySettings.query.get_or_404(company_id)
if current_user.role=='boss' and current_user.company_id!=settings.id:return fail('Brak uprawnień',403)
if region is not None: s.selected_region=region
s.active=boolv(d,'active');allowed=boolv(d,'allowed')
if allowed and s not in settings.allowed_stations: settings.allowed_stations.append(s)
if not allowed and s in settings.allowed_stations: settings.allowed_stations.remove(s)
db.session.commit();socketio.emit('app_changed',{'resource':'stations','company_id':settings.id});return response(station_dict(s),f'Zapisano ustawienia stacji dla firmy {settings.name}')
@api.get('/companies')
@role_required('boss','admin')
def companies_list():
return response([{'id':c.id,'name':c.name,'entity_type':c.entity_type,'region':c.region,'active':c.active,'users_count':len(c.users),'cards_count':FuelCard.query.filter_by(company_id=c.id).count()} for c in CompanySettings.query.order_by(CompanySettings.name).all()])
@api.post('/companies')
@role_required('admin')
def companies_create():
d=payload()
try:
c=CompanySettings(name=(d.get('name') or '').strip(),entity_type=d.get('entity_type','JDG'),vat_rate=Decimal(d.get('vat_rate') or 23),vat_deduction_percent=Decimal(d.get('vat_deduction_percent') or 50),region=(d.get('region') or 'mazowieckie').lower(),invoice_split_day=int(d.get('invoice_split_day') or 15),active=True,station_access_mode='all_prefer_favorites')
if not c.name: raise ValueError('Nazwa firmy jest wymagana')
db.session.add(c);db.session.commit();return response({'id':c.id,'redirect':url_for('main.admin_companies',company_id=c.id)},'Dodano firmę',201)
except Exception as exc:db.session.rollback();return fail(str(exc))
@api.delete('/companies/<int:company_id>')
@role_required('admin')
def companies_delete(company_id):
company = CompanySettings.query.get_or_404(company_id)
blockers = []
users_count = User.query.filter_by(company_id=company.id).count()
vehicles_count = Vehicle.query.filter_by(company_id=company.id).count()
cards_count = FuelCard.query.filter_by(company_id=company.id).count()
if users_count:
blockers.append(f'{users_count} użytkowników')
if vehicles_count:
blockers.append(f'{vehicles_count} pojazdów')
if cards_count:
blockers.append(f'{cards_count} kart paliwowych')
if blockers:
return fail(
'Nie można usunąć firmy, ponieważ ma przypisane: ' + ', '.join(blockers) +
'. Najpierw przenieś lub usuń te dane albo dezaktywuj firmę.',
409,
)
company.favorite_stations.clear()
company.allowed_stations.clear()
db.session.delete(company)
db.session.commit()
socketio.emit('app_changed', {'resource':'company','deleted_id':company_id})
return response({'redirect':url_for('main.admin_companies')}, 'Usunięto firmę')
@api.put('/companies/<int:company_id>')
@role_required('boss','admin')
def companies_update(company_id):
c=CompanySettings.query.get_or_404(company_id);d=payload()
if current_user.role=='boss' and current_user.company_id!=c.id:return fail('Brak uprawnień',403)
try:
c.name=(d.get('name') or c.name).strip();c.entity_type=d.get('entity_type',c.entity_type);c.vat_rate=Decimal(d.get('vat_rate') or c.vat_rate);c.vat_deduction_percent=Decimal(d.get('vat_deduction_percent') or c.vat_deduction_percent);c.region=(d.get('region') or c.region).lower();c.invoice_split_day=int(d.get('invoice_split_day') or c.invoice_split_day);c.active=boolv(d,'active')
db.session.commit();socketio.emit('app_changed',{'resource':'company','id':c.id});return response(message='Zapisano firmę')
except Exception as exc:db.session.rollback();return fail(str(exc))
@api.put('/companies/<int:company_id>/favorite-stations')
@role_required('boss','admin')
def company_favorites(company_id):
c=CompanySettings.query.get_or_404(company_id)
if current_user.role=='boss' and current_user.company_id!=c.id:return fail('Brak uprawnień',403)
d=payload();ids=request.form.getlist('station_ids') if request.form else d.get('station_ids',[]);ids=[ids] if isinstance(ids,(str,int)) else ids
try: ids=[int(x) for x in ids]
except Exception:return fail('Nieprawidłowa lista stacji')
if len(ids)>25:return fail('Firma może mieć maksymalnie 25 ulubionych stacji')
c.favorite_stations=FuelStationCompany.query.filter(FuelStationCompany.id.in_(ids),FuelStationCompany.active.is_(True)).all() if ids else []
db.session.commit();socketio.emit('app_changed',{'resource':'company_favorites','company_id':c.id});return response(message='Zapisano ulubione stacje firmy')
@api.put('/companies/<int:company_id>/station-access')
@role_required('boss','admin')
def company_station_access(company_id):
company=CompanySettings.query.get_or_404(company_id)
if current_user.role=='boss' and current_user.company_id!=company.id:return fail('Brak uprawnień',403)
d=payload();mode=(d.get('station_access_mode') or '').strip()
modes={'all_prefer_favorites','all','allowed_only','favorites_allowed_only'}
if mode not in modes:return fail('Nieprawidłowy tryb dostępu do stacji')
company.station_access_mode=mode
db.session.commit();socketio.emit('app_changed',{'resource':'station_access','company_id':company.id})
return response({'station_access_mode':mode},'Zapisano politykę dostępu do stacji')
@api.put('/companies/<int:company_id>/allowed-stations')
@role_required('boss','admin')
def company_allowed_stations(company_id):
company=CompanySettings.query.get_or_404(company_id)
if current_user.role=='boss' and current_user.company_id!=company.id:return fail('Brak uprawnień',403)
d=payload();ids=request.form.getlist('station_ids') if request.form else d.get('station_ids',[]);ids=[ids] if isinstance(ids,(str,int)) else ids
try: ids=[int(x) for x in ids]
except Exception:return fail('Nieprawidłowa lista stacji')
company.allowed_stations=FuelStationCompany.query.filter(FuelStationCompany.id.in_(ids),FuelStationCompany.active.is_(True)).all() if ids else []
db.session.commit();socketio.emit('app_changed',{'resource':'allowed_stations','company_id':company.id})
message='Wszystkie aktywne stacje są dozwolone' if not ids else 'Zapisano zamkniętą listę dozwolonych stacji'
return response([station_dict(x) for x in company.allowed_stations],message)
@api.post('/companies/<int:company_id>/favorite-stations/<int:station_id>/toggle')
@role_required('boss','admin')
def company_favorite_toggle(company_id,station_id):
c=CompanySettings.query.get_or_404(company_id);s=FuelStationCompany.query.get_or_404(station_id)
if current_user.role=='boss' and current_user.company_id!=c.id:return fail('Brak uprawnień',403)
if s in c.favorite_stations:c.favorite_stations.remove(s);state=False
else:
if len(c.favorite_stations)>=25:return fail('Firma może mieć maksymalnie 25 ulubionych stacji')
c.favorite_stations.append(s);state=True
db.session.commit();socketio.emit('app_changed',{'resource':'company_favorites','company_id':c.id});return response({'favorite':state},'Zmieniono ulubione stacje')
+5
View File
@@ -0,0 +1,5 @@
from flask import Blueprint
api = Blueprint("api", __name__, url_prefix="/api")
from . import auth, companies, fuel_cards, fuel_entries, orlen, settings, stations, users, vehicles # noqa: E402,F401
+44
View File
@@ -0,0 +1,44 @@
from collections import defaultdict
from datetime import date, datetime
from decimal import Decimal
from flask import current_app, jsonify, request, url_for
from flask_login import current_user, login_required, login_user
from sqlalchemy import extract
from sqlalchemy.exc import IntegrityError
from . import api
from .common import accessible_vehicles, boolv, fail, payload, response, role_required, station_dict, user_dict, vehicle_dict
from .. import THEMES
from ..api_tokens import create_access_token
from ..extensions import db
from ..models import AppSetting, CompanySettings, FuelCard, FuelCardPolicy, FuelCardStationRule, FuelEntry, FuelStationCompany, FuelStationPoint, OrlenPrice, User, Vehicle
from ..services import POLISH_REGIONS, aggregate_ure_companies, calculate_costs, fetch_orlen_price, fetch_orlen_range, fetch_ure_stations, invoice_period
from ..station_catalog import sync_station_catalog
FUEL_TYPES = ("PB95", "PB98", "DIESEL", "LPG")
@api.post('/auth/login')
def login():
d=payload(); user=User.query.filter_by(email=(d.get('email') or '').strip().lower()).first()
if not user or not user.check_password(d.get('password') or '') or not user.active: return fail('Nieprawidłowy e-mail lub hasło',401)
login_user(user); return response({'user':user_dict(user),'redirect':url_for('main.dashboard')},'Zalogowano')
@api.post('/auth/token')
def token_login():
d = payload()
email = (d.get('email') or d.get('username') or '').strip().lower()
user = User.query.filter_by(email=email).first()
if not user or not user.check_password(d.get('password') or '') or not user.active:
return jsonify({'error':'invalid_grant','error_description':'Nieprawidłowy e-mail lub hasło'}), 401
expires_in = int(current_app.config['API_TOKEN_MAX_AGE_SECONDS'])
return jsonify({
'access_token': create_access_token(user),
'token_type': 'Bearer',
'expires_in': expires_in,
'user': user_dict(user),
})
@api.get('/me')
@login_required
def me(): return response(user_dict(current_user))
+82
View File
@@ -0,0 +1,82 @@
from functools import wraps
from flask import jsonify, request
from flask_login import current_user, login_required
from ..extensions import db
from ..models import CompanySettings, FuelStationCompany, User, Vehicle
def response(data=None, message=None, status=200):
payload = {"ok": status < 400}
if message:
payload["message"] = message
if data is not None:
payload["data"] = data
return jsonify(payload), status
def fail(message, status=400):
return response(message=message, status=status)
def role_required(*allowed):
def decorator(function):
@wraps(function)
@login_required
def wrapped(*args, **kwargs):
if current_user.role not in allowed:
return fail("Brak uprawnień", 403)
return function(*args, **kwargs)
return wrapped
return decorator
def payload():
return request.get_json(silent=True) or request.form
def boolv(data, key):
value = data.get(key)
return value in (True, 1, "1", "true", "on", "yes")
def accessible_vehicles():
if current_user.role == "admin":
return Vehicle.query.order_by(Vehicle.name).all()
ids = {vehicle.id for vehicle in current_user.owned_vehicles + current_user.shared_vehicles}
return Vehicle.query.filter(Vehicle.id.in_(ids)).order_by(Vehicle.name).all() if ids else []
def user_dict(user):
return {
"id": user.id, "name": user.name, "email": user.email, "role": user.role,
"active": user.active, "fuel_card_id": user.fuel_card_id,
"fuel_card": user.fuel_card.name if user.fuel_card else None,
"company_id": user.company_id, "company": user.company.name if user.company else None,
}
def vehicle_dict(vehicle):
return {
"id": vehicle.id, "name": vehicle.name, "make": vehicle.make, "model": vehicle.model,
"registration": vehicle.registration, "fuel_type": vehicle.fuel_type,
"owner": user_dict(vehicle.owner), "drivers": [user_dict(x) for x in vehicle.drivers],
"has_fuel_card": bool(vehicle.fuel_card_id), "fuel_card_id": vehicle.fuel_card_id,
"fuel_card": vehicle.fuel_card.name if vehicle.fuel_card else None,
"current_odometer": vehicle.current_odometer,
}
def station_dict(station):
settings = CompanySettings.query.first()
return {
"id": station.id, "brand_name": station.display_name, "company_name": station.company_name,
"nip": station.nip, "regon": station.regon, "brands": station.station_name_list,
"regions": station.region_list, "selected_region": station.selected_region,
"station_count": station.station_count, "has_petrol": station.has_petrol,
"has_diesel": station.has_diesel, "has_lpg": station.has_lpg,
"use_orlen_last_price": station.use_orlen_last_price, "active": station.active,
"favorite": station in current_user.favorite_stations if current_user.is_authenticated else False,
"allowed": station in settings.allowed_stations if settings else True,
}
+119
View File
@@ -0,0 +1,119 @@
from collections import defaultdict
from datetime import date, datetime
from decimal import Decimal
from flask import current_app, jsonify, request, url_for
from flask_login import current_user, login_required, login_user
from sqlalchemy import extract
from sqlalchemy.exc import IntegrityError
from . import api
from .common import accessible_vehicles, boolv, fail, payload, response, role_required, station_dict, user_dict, vehicle_dict
from .. import THEMES
from ..api_tokens import create_access_token
from ..extensions import db
from ..models import AppSetting, CompanySettings, FuelCard, FuelCardPolicy, FuelCardStationRule, FuelEntry, FuelStationCompany, FuelStationPoint, OrlenPrice, User, Vehicle
from ..services import POLISH_REGIONS, aggregate_ure_companies, calculate_costs, fetch_orlen_price, fetch_orlen_range, fetch_ure_stations, invoice_period
from ..station_catalog import sync_station_catalog
FUEL_TYPES = ("PB95", "PB98", "DIESEL", "LPG")
@api.get('/companies')
@role_required('boss','admin')
def companies_list():
return response([{'id':c.id,'name':c.name,'entity_type':c.entity_type,'region':c.region,'active':c.active,'users_count':len(c.users),'cards_count':FuelCard.query.filter_by(company_id=c.id).count()} for c in CompanySettings.query.order_by(CompanySettings.name).all()])
@api.post('/companies')
@role_required('admin')
def companies_create():
d=payload()
try:
c=CompanySettings(name=(d.get('name') or '').strip(),entity_type=d.get('entity_type','JDG'),vat_rate=Decimal(d.get('vat_rate') or 23),vat_deduction_percent=Decimal(d.get('vat_deduction_percent') or 50),region=(d.get('region') or 'mazowieckie').lower(),invoice_split_day=int(d.get('invoice_split_day') or 15),active=True,station_access_mode='all_prefer_favorites')
if not c.name: raise ValueError('Nazwa firmy jest wymagana')
db.session.add(c);db.session.commit();return response({'id':c.id,'redirect':url_for('main.admin_companies',company_id=c.id)},'Dodano firmę',201)
except Exception as exc:db.session.rollback();return fail(str(exc))
@api.delete('/companies/<int:company_id>')
@role_required('admin')
def companies_delete(company_id):
company = CompanySettings.query.get_or_404(company_id)
blockers = []
users_count = User.query.filter_by(company_id=company.id).count()
vehicles_count = Vehicle.query.filter_by(company_id=company.id).count()
cards_count = FuelCard.query.filter_by(company_id=company.id).count()
if users_count:
blockers.append(f'{users_count} użytkowników')
if vehicles_count:
blockers.append(f'{vehicles_count} pojazdów')
if cards_count:
blockers.append(f'{cards_count} kart paliwowych')
if blockers:
return fail(
'Nie można usunąć firmy, ponieważ ma przypisane: ' + ', '.join(blockers) +
'. Najpierw przenieś lub usuń te dane albo dezaktywuj firmę.',
409,
)
company.favorite_stations.clear()
company.allowed_stations.clear()
db.session.delete(company)
db.session.commit()
return response({'redirect':url_for('main.admin_companies')}, 'Usunięto firmę')
@api.put('/companies/<int:company_id>')
@role_required('boss','admin')
def companies_update(company_id):
c=CompanySettings.query.get_or_404(company_id);d=payload()
if current_user.role=='boss' and current_user.company_id!=c.id:return fail('Brak uprawnień',403)
try:
c.name=(d.get('name') or c.name).strip();c.entity_type=d.get('entity_type',c.entity_type);c.vat_rate=Decimal(d.get('vat_rate') or c.vat_rate);c.vat_deduction_percent=Decimal(d.get('vat_deduction_percent') or c.vat_deduction_percent);c.region=(d.get('region') or c.region).lower();c.invoice_split_day=int(d.get('invoice_split_day') or c.invoice_split_day);c.active=boolv(d,'active')
db.session.commit();return response(message='Zapisano firmę')
except Exception as exc:db.session.rollback();return fail(str(exc))
@api.put('/companies/<int:company_id>/favorite-stations')
@role_required('boss','admin')
def company_favorites(company_id):
c=CompanySettings.query.get_or_404(company_id)
if current_user.role=='boss' and current_user.company_id!=c.id:return fail('Brak uprawnień',403)
d=payload();ids=request.form.getlist('station_ids') if request.form else d.get('station_ids',[]);ids=[ids] if isinstance(ids,(str,int)) else ids
try: ids=[int(x) for x in ids]
except Exception:return fail('Nieprawidłowa lista stacji')
if len(ids)>25:return fail('Firma może mieć maksymalnie 25 ulubionych stacji')
c.favorite_stations=FuelStationCompany.query.filter(FuelStationCompany.id.in_(ids),FuelStationCompany.active.is_(True)).all() if ids else []
db.session.commit();return response(message='Zapisano ulubione stacje firmy')
@api.put('/companies/<int:company_id>/station-access')
@role_required('boss','admin')
def company_station_access(company_id):
company=CompanySettings.query.get_or_404(company_id)
if current_user.role=='boss' and current_user.company_id!=company.id:return fail('Brak uprawnień',403)
d=payload();mode=(d.get('station_access_mode') or '').strip()
modes={'all_prefer_favorites','all','allowed_only','favorites_allowed_only'}
if mode not in modes:return fail('Nieprawidłowy tryb dostępu do stacji')
company.station_access_mode=mode
db.session.commit()
return response({'station_access_mode':mode},'Zapisano politykę dostępu do stacji')
@api.put('/companies/<int:company_id>/allowed-stations')
@role_required('boss','admin')
def company_allowed_stations(company_id):
company=CompanySettings.query.get_or_404(company_id)
if current_user.role=='boss' and current_user.company_id!=company.id:return fail('Brak uprawnień',403)
d=payload();ids=request.form.getlist('station_ids') if request.form else d.get('station_ids',[]);ids=[ids] if isinstance(ids,(str,int)) else ids
try: ids=[int(x) for x in ids]
except Exception:return fail('Nieprawidłowa lista stacji')
company.allowed_stations=FuelStationCompany.query.filter(FuelStationCompany.id.in_(ids),FuelStationCompany.active.is_(True)).all() if ids else []
db.session.commit()
message='Wszystkie aktywne stacje są dozwolone' if not ids else 'Zapisano zamkniętą listę dozwolonych stacji'
return response([station_dict(x) for x in company.allowed_stations],message)
@api.post('/companies/<int:company_id>/favorite-stations/<int:station_id>/toggle')
@role_required('boss','admin')
def company_favorite_toggle(company_id,station_id):
c=CompanySettings.query.get_or_404(company_id);s=FuelStationCompany.query.get_or_404(station_id)
if current_user.role=='boss' and current_user.company_id!=c.id:return fail('Brak uprawnień',403)
if s in c.favorite_stations:c.favorite_stations.remove(s);state=False
else:
if len(c.favorite_stations)>=25:return fail('Firma może mieć maksymalnie 25 ulubionych stacji')
c.favorite_stations.append(s);state=True
db.session.commit();return response({'favorite':state},'Zmieniono ulubione stacje')
+81
View File
@@ -0,0 +1,81 @@
from collections import defaultdict
from datetime import date, datetime
from decimal import Decimal
from flask import current_app, jsonify, request, url_for
from flask_login import current_user, login_required, login_user
from sqlalchemy import extract
from sqlalchemy.exc import IntegrityError
from . import api
from .common import accessible_vehicles, boolv, fail, payload, response, role_required, station_dict, user_dict, vehicle_dict
from .. import THEMES
from ..api_tokens import create_access_token
from ..extensions import db
from ..models import AppSetting, CompanySettings, FuelCard, FuelCardPolicy, FuelCardStationRule, FuelEntry, FuelStationCompany, FuelStationPoint, OrlenPrice, User, Vehicle
from ..services import POLISH_REGIONS, aggregate_ure_companies, calculate_costs, fetch_orlen_price, fetch_orlen_range, fetch_ure_stations, invoice_period
from ..station_catalog import sync_station_catalog
FUEL_TYPES = ("PB95", "PB98", "DIESEL", "LPG")
@api.get('/fuel-cards')
@login_required
def get_fuel_cards():
rows=FuelCard.query.order_by(FuelCard.name).all()
return response([{'id':x.id,'name':x.name,'provider':x.provider,'active':x.active,'description':x.description} for x in rows])
@api.post('/fuel-cards')
@role_required('boss','admin')
def create_fuel_card():
d=payload()
try:
company_id=int(d.get('company_id') or current_user.company_id or CompanySettings.query.first().id)
if current_user.role=='boss' and company_id!=current_user.company_id:return fail('Brak uprawnień',403)
card=FuelCard(name=d['name'].strip(),provider=d['provider'].strip(),description=(d.get('description') or '').strip() or None,active=True,company_id=company_id)
db.session.add(card);db.session.commit();return response({'id':card.id},'Dodano kartę paliwową',201)
except Exception as exc:db.session.rollback();return fail(str(getattr(exc,'orig',exc)))
@api.put('/fuel-cards/<int:card_id>')
@role_required('boss','admin')
def update_fuel_card(card_id):
card=FuelCard.query.get_or_404(card_id);d=payload();card.name=d.get('name',card.name).strip();card.provider=d.get('provider',card.provider).strip();card.description=(d.get('description') or '').strip() or None;card.active=boolv(d,'active');db.session.commit();return response(message='Zapisano kartę')
@api.delete('/fuel-cards/<int:card_id>')
@role_required('boss','admin')
def delete_fuel_card(card_id):
card = FuelCard.query.get_or_404(card_id)
if current_user.role == 'boss' and card.company_id != current_user.company_id:
return fail('Brak uprawnień', 403)
users_count = User.query.filter_by(fuel_card_id=card.id).count()
vehicles_count = Vehicle.query.filter_by(fuel_card_id=card.id).count()
entries_count = FuelEntry.query.filter_by(fuel_card_id=card.id).count()
blockers = []
if users_count:
blockers.append(f'{users_count} użytkowników')
if vehicles_count:
blockers.append(f'{vehicles_count} pojazdów')
if entries_count:
blockers.append(f'{entries_count} tankowań')
if blockers:
return fail(
'Nie można usunąć karty, ponieważ jest przypisana do: ' + ', '.join(blockers) +
'. Najpierw odłącz kartę od użytkowników i pojazdów. Karty użytej w tankowaniach nie można usunąć; możesz ją wyłączyć.',
409,
)
company_id = card.company_id
db.session.delete(card)
db.session.commit()
return response(
{'redirect':url_for('main.admin_companies', company_id=company_id)},
'Usunięto kartę flotową',
)
@api.put('/fuel-cards/<int:card_id>/stations/<int:station_id>')
@role_required('boss','admin')
def update_card_station_rule(card_id,station_id):
FuelCard.query.get_or_404(card_id);FuelStationCompany.query.get_or_404(station_id);d=payload()
rule=FuelCardStationRule.query.filter_by(fuel_card_id=card_id,station_company_id=station_id).first() or FuelCardStationRule(fuel_card_id=card_id,station_company_id=station_id)
rule.allowed=boolv(d,'allowed');rule.use_orlen_last_price=boolv(d,'use_orlen_last_price');rule.discount_net_percent=Decimal(d.get('discount_net_percent') or 0);rule.surcharge_net_per_liter=Decimal(d.get('surcharge_net_per_liter') or 0)
db.session.add(rule);db.session.commit();return response(message='Zapisano warunki karty dla stacji')
+52
View File
@@ -0,0 +1,52 @@
from collections import defaultdict
from datetime import date, datetime
from decimal import Decimal
from flask import current_app, jsonify, request, url_for
from flask_login import current_user, login_required, login_user
from sqlalchemy import extract
from sqlalchemy.exc import IntegrityError
from . import api
from .common import accessible_vehicles, boolv, fail, payload, response, role_required, station_dict, user_dict, vehicle_dict
from .. import THEMES
from ..api_tokens import create_access_token
from ..extensions import db
from ..models import AppSetting, CompanySettings, FuelCard, FuelCardPolicy, FuelCardStationRule, FuelEntry, FuelStationCompany, FuelStationPoint, OrlenPrice, User, Vehicle
from ..services import POLISH_REGIONS, aggregate_ure_companies, calculate_costs, fetch_orlen_price, fetch_orlen_range, fetch_ure_stations, invoice_period
from ..station_catalog import sync_station_catalog
from ..vat import freeze_vat_action
from ..settlement import freeze as freeze_settlement
FUEL_TYPES = ("PB95", "PB98", "DIESEL", "LPG")
@api.post('/fuel-entries')
@login_required
def create_fuel_entry():
d=payload(); vehicles=accessible_vehicles()
try: vehicle=next((v for v in vehicles if v.id==int(d['vehicle_id'])),None)
except Exception: vehicle=None
if not vehicle:return fail('Brak dostępu do pojazdu',403)
try:
odo=int(d['odometer']); fueled_at=datetime.fromisoformat(d['fueled_at']); station_company=db.session.get(FuelStationCompany,int(d['station_company_id'])) if d.get('station_company_id') else None
settings=vehicle.company or current_user.company or CompanySettings.query.first()
access_mode=settings.station_access_mode or 'all_prefer_favorites'
if station_company and access_mode=='allowed_only' and station_company not in settings.allowed_stations:
return fail('Ta stacja nie jest dozwolona przez politykę firmy',403)
if station_company and access_mode=='favorites_allowed_only' and station_company not in settings.allowed_stations and station_company not in settings.favorite_stations:
return fail('Ta stacja nie jest ulubiona ani dozwolona przez politykę firmy',403)
wholesale=source=None; region=(station_company.selected_region if station_company else None) or settings.region
try:
p=fetch_orlen_price(d['fuel_type'],fueled_at.date(),region);wholesale=p['price_per_liter'];source=p['source']
except Exception: pass
card_id=int(d['fuel_card_id']) if d.get('fuel_card_id') else (vehicle.fuel_card_id or current_user.fuel_card_id)
card=db.session.get(FuelCard,card_id) if card_id else None
used_card=boolv(d,'used_fuel_card') and bool(card)
if used_card and station_company:
rule=FuelCardStationRule.query.filter_by(fuel_card_id=card.id,station_company_id=station_company.id).first()
if rule and not rule.allowed:return fail('Ta stacja jest zablokowana dla wybranej karty paliwowej',403)
e=FuelEntry(vehicle=vehicle,user_id=current_user.id,fueled_at=fueled_at,liters=Decimal(d['liters']),price_per_liter=Decimal(d['price_per_liter']),fuel_type=d['fuel_type'],odometer=odo,station=station_company.company_name if station_company else d.get('station'),station_company_id=station_company.id if station_company else None,fuel_card_id=card.id if used_card else None,invoice_number=d.get('invoice_number'),used_fuel_card=used_card,wholesale_price=wholesale,wholesale_source=source)
freeze_vat_action(e, settings.vat_rate)
freeze_settlement(e, settings, source="Automatycznie przy dodaniu przez API")
vehicle.current_odometer=odo;db.session.add(e);db.session.commit();return response({'id':e.id,'redirect':url_for('main.dashboard')},'Zapisano tankowanie',201)
except Exception as exc:db.session.rollback();return fail(str(exc),400)
+62
View File
@@ -0,0 +1,62 @@
from collections import defaultdict
from datetime import date, datetime
from decimal import Decimal
from flask import current_app, jsonify, request, url_for
from flask_login import current_user, login_required, login_user
from sqlalchemy import extract
from sqlalchemy.exc import IntegrityError
from . import api
from .common import accessible_vehicles, boolv, fail, payload, response, role_required, station_dict, user_dict, vehicle_dict
from .. import THEMES
from ..api_tokens import create_access_token
from ..extensions import db
from ..models import AppSetting, CompanySettings, FuelCard, FuelCardPolicy, FuelCardStationRule, FuelEntry, FuelStationCompany, FuelStationPoint, OrlenPrice, User, Vehicle
from ..services import POLISH_REGIONS, aggregate_ure_companies, calculate_costs, fetch_orlen_price, fetch_orlen_range, fetch_ure_stations, invoice_period
from ..station_catalog import sync_station_catalog
from ..orlen_sync import sync_orlen_prices
FUEL_TYPES = ("PB95", "PB98", "DIESEL", "LPG")
@api.get('/orlen/prices')
@login_required
def orlen_prices():
year=request.args.get('year',type=int) or date.today().year; fuels=[x.upper() for x in request.args.getlist('fuel') if x.upper() in FUEL_TYPES] or ['PB95']; regions=[x.lower() for x in request.args.getlist('region') if x.lower() in POLISH_REGIONS]
rows=OrlenPrice.query.filter(OrlenPrice.fuel_type.in_(fuels),extract('year',OrlenPrice.effective_date)==year).order_by(OrlenPrice.effective_date).all()
return response([{'fuel_type':r.fuel_type,'date':r.effective_date.isoformat(),'price_per_liter':float(r.price_per_liter),'region':r.region,'source':r.source} for r in rows if r.fuel_type!='LPG' or not regions or r.region in regions])
@api.post('/orlen/sync')
@role_required('boss','admin')
def orlen_sync():
d = payload()
fuels = d.get('fuels', [])
fuels = [fuels] if isinstance(fuels, str) else fuels
fuels = [fuel.upper() for fuel in fuels if fuel.upper() in FUEL_TYPES]
regions = d.get('regions', [])
regions = [regions] if isinstance(regions, str) else regions
regions = [str(region).strip().lower() for region in regions if str(region).strip().lower() in POLISH_REGIONS]
if not fuels:
return fail('Wybierz co najmniej jedno paliwo')
try:
year = int(d.get('year', date.today().year))
settings = CompanySettings.query.first()
result, total = sync_orlen_prices(
fuels=fuels,
year=year,
regions=regions,
default_region=settings.region,
full_refresh=boolv(d, 'full_refresh'),
)
db.session.commit()
return response(result, f'Przetworzono {total} rekordów')
except Exception as exc:
db.session.rollback()
return fail(str(exc), 502)
@api.get('/orlen/preview')
@login_required
def orlen_preview():
try:return response(fetch_orlen_price(request.args['fuel_type'],datetime.fromisoformat(request.args['date']).date(),request.args.get('region') or CompanySettings.query.first().region))
except Exception as exc:return fail(str(exc),502)
+46
View File
@@ -0,0 +1,46 @@
from collections import defaultdict
from datetime import date, datetime
from decimal import Decimal
from flask import current_app, jsonify, request, url_for
from flask_login import current_user, login_required, login_user
from sqlalchemy import extract
from sqlalchemy.exc import IntegrityError
from . import api
from .common import accessible_vehicles, boolv, fail, payload, response, role_required, station_dict, user_dict, vehicle_dict
from .. import THEMES
from ..api_tokens import create_access_token
from ..extensions import db
from ..models import AppSetting, CompanySettings, FuelCard, FuelCardPolicy, FuelCardStationRule, FuelEntry, FuelStationCompany, FuelStationPoint, OrlenPrice, User, Vehicle
from ..services import POLISH_REGIONS, aggregate_ure_companies, calculate_costs, fetch_orlen_price, fetch_orlen_range, fetch_ure_stations, invoice_period
from ..station_catalog import sync_station_catalog
FUEL_TYPES = ("PB95", "PB98", "DIESEL", "LPG")
@api.get('/settings')
@role_required('boss','admin')
def get_settings():
s=CompanySettings.query.first();return response({'name':s.name,'entity_type':s.entity_type,'vat_rate':float(s.vat_rate),'vat_deduction_percent':float(s.vat_deduction_percent),'region':s.region,'invoice_split_day':s.invoice_split_day,'invoice_split_enabled':(db.session.get(AppSetting,'invoice_split_enabled').value=='1')})
@api.put('/settings')
@role_required('boss','admin')
def update_settings():
d=payload();s=CompanySettings.query.first()
try:
s.name=d['name'];s.entity_type=d['entity_type'];s.vat_rate=Decimal(d['vat_rate']);s.vat_deduction_percent=Decimal(d['vat_deduction_percent']);s.region=d['region'].strip().lower();s.invoice_split_day=int(d.get('invoice_split_day') or 15)
db.session.merge(AppSetting(key='invoice_split_enabled',value='1' if boolv(d,'invoice_split_enabled') else '0'));db.session.commit();return response(message='Zapisano ustawienia')
except Exception as exc:db.session.rollback();return fail(str(exc))
@api.get('/app-settings')
@role_required('boss','admin')
def get_app_settings():
return response({'theme':(db.session.get(AppSetting,'theme') or AppSetting(value='bootstrap')).value})
@api.put('/app-settings')
@role_required('boss','admin')
def update_app_settings():
d=payload();theme=d.get('theme','bootstrap')
if theme not in THEMES:return fail('Nieprawidłowy motyw')
db.session.merge(AppSetting(key='theme',value=theme));db.session.commit();return response(message='Zapisano ustawienia aplikacji')
+96
View File
@@ -0,0 +1,96 @@
from collections import defaultdict
from datetime import date, datetime
from decimal import Decimal
from flask import current_app, jsonify, request, url_for
from flask_login import current_user, login_required, login_user
from sqlalchemy import extract
from sqlalchemy.exc import IntegrityError
from . import api
from .common import accessible_vehicles, boolv, fail, payload, response, role_required, station_dict, user_dict, vehicle_dict
from .. import THEMES
from ..api_tokens import create_access_token
from ..extensions import db
from ..models import AppSetting, CompanySettings, FuelCard, FuelCardPolicy, FuelCardStationRule, FuelEntry, FuelStationCompany, FuelStationPoint, OrlenPrice, User, Vehicle
from ..services import POLISH_REGIONS, aggregate_ure_companies, calculate_costs, fetch_orlen_price, fetch_orlen_range, fetch_ure_stations, invoice_period
from ..station_catalog import sync_station_catalog
FUEL_TYPES = ("PB95", "PB98", "DIESEL", "LPG")
@api.get('/stations')
@login_required
def stations():
q=request.args.get('q','').strip();page=request.args.get('page',1,type=int);per=min(request.args.get('per_page',30,type=int),100);query=FuelStationCompany.query.filter_by(active=True)
if q:
for term in [x for x in q.split() if x]:
needle=f'%{term}%'
query=query.filter(db.or_(FuelStationCompany.brand_name.ilike(needle),FuelStationCompany.company_name.ilike(needle),FuelStationCompany.nip.ilike(needle),FuelStationCompany.regon.ilike(needle),FuelStationCompany.station_names.ilike(needle)))
sort=request.args.get('sort','company_name');direction=request.args.get('direction','asc')
allowed={'company_name':FuelStationCompany.brand_name,'station_count':FuelStationCompany.station_count,'nip':FuelStationCompany.nip,'regon':FuelStationCompany.regon,'selected_region':FuelStationCompany.selected_region}
col=allowed.get(sort,FuelStationCompany.company_name);query=query.order_by(col.desc() if direction=='desc' else col.asc(),FuelStationCompany.brand_name.asc())
p=query.paginate(page=page,per_page=per,error_out=False);return response({'items':[station_dict(x) for x in p.items],'page':p.page,'pages':p.pages,'total':p.total,'sort':sort,'direction':direction})
@api.get('/stations/<int:station_id>/points')
@login_required
def station_points(station_id):
station=FuelStationCompany.query.get_or_404(station_id)
page=max(request.args.get('page',1,type=int),1)
per_page=min(max(request.args.get('per_page',50,type=int),1),100)
q=(request.args.get('q') or '').strip()
query=FuelStationPoint.query.filter_by(station_company_id=station.id)
if q:
needle=f'%{q}%'
query=query.filter(db.or_(FuelStationPoint.station_name.ilike(needle),FuelStationPoint.street.ilike(needle),FuelStationPoint.city.ilike(needle),FuelStationPoint.postal_code.ilike(needle)))
query=query.order_by(FuelStationPoint.city.asc(),FuelStationPoint.street.asc(),FuelStationPoint.street_number.asc())
result=query.paginate(page=page,per_page=per_page,error_out=False)
items=[{'id':x.id,'dkn':x.ure_dkn.split(':',1)[0] if x.ure_dkn else '','name':x.station_name,'address':x.address,'city':x.city,'region':x.region,'coordinates':x.coordinates,'has_petrol':x.has_petrol,'has_diesel':x.has_diesel,'has_lpg':x.has_lpg} for x in result.items]
return response({'station':station_dict(station),'items':items,'page':result.page,'pages':result.pages,'total':result.total})
@api.put('/me/favorite-stations')
@login_required
def favorite_stations():
d=payload();ids=request.form.getlist('station_ids') if request.form else d.get('station_ids',[]);ids=[ids] if isinstance(ids,(str,int)) else ids
try: ids=[int(x) for x in ids]
except Exception: return fail('Nieprawidłowa lista stacji')
if len(ids)>10:return fail('Użytkownik może mieć maksymalnie 10 ulubionych stacji')
current_user.favorite_stations=FuelStationCompany.query.filter(FuelStationCompany.id.in_(ids),FuelStationCompany.active.is_(True)).all() if ids else []
db.session.commit();return response([station_dict(x) for x in current_user.favorite_stations],'Zapisano ulubione stacje')
@api.put('/settings/allowed-stations')
@role_required('boss','admin')
def allowed_stations():
d=payload();ids=request.form.getlist('station_ids') if request.form else d.get('station_ids',[]);ids=[ids] if isinstance(ids,(str,int)) else ids
try: ids=[int(x) for x in ids]
except Exception: return fail('Nieprawidłowa lista stacji')
settings=CompanySettings.query.first();settings.allowed_stations=FuelStationCompany.query.filter(FuelStationCompany.id.in_(ids),FuelStationCompany.active.is_(True)).all() if ids else []
db.session.commit();return response([station_dict(x) for x in settings.allowed_stations],'Zapisano dozwolone stacje')
@api.post('/stations/sync')
@role_required('boss','admin')
def stations_sync():
try:
companies=aggregate_ure_companies(fetch_ure_stations())
added,updated=sync_station_catalog(companies)
db.session.commit();return response({'added':added,'updated':updated},f'Katalog URE: dodano {added}, zaktualizowano {updated}')
except Exception as exc:db.session.rollback();return fail(f'Nie udało się pobrać danych URE: {exc}',502)
@api.put('/stations/<int:station_id>')
@role_required('boss','admin')
def update_station(station_id):
s=FuelStationCompany.query.get_or_404(station_id);d=payload()
region=None
if 'selected_region' in d:
region=(d.get('selected_region') or '').strip().lower()
if region and region not in s.region_list:return fail('Wybrane województwo nie występuje w danych tej firmy')
try:
company_id=int(d.get('company_id') or current_user.company_id or 0)
except (TypeError,ValueError):
return fail('Nieprawidłowa firma')
settings=CompanySettings.query.get_or_404(company_id)
if current_user.role=='boss' and current_user.company_id!=settings.id:return fail('Brak uprawnień',403)
if region is not None: s.selected_region=region
s.active=boolv(d,'active');allowed=boolv(d,'allowed')
if allowed and s not in settings.allowed_stations: settings.allowed_stations.append(s)
if not allowed and s in settings.allowed_stations: settings.allowed_stations.remove(s)
db.session.commit();return response(station_dict(s),f'Zapisano ustawienia stacji dla firmy {settings.name}')
+71
View File
@@ -0,0 +1,71 @@
from collections import defaultdict
from datetime import date, datetime
from decimal import Decimal
from flask import current_app, jsonify, request, url_for
from flask_login import current_user, login_required, login_user
from sqlalchemy import extract
from sqlalchemy.exc import IntegrityError
from . import api
from .common import accessible_vehicles, boolv, fail, payload, response, role_required, station_dict, user_dict, vehicle_dict
from .. import THEMES
from ..api_tokens import create_access_token
from ..extensions import db
from ..models import AppSetting, CompanySettings, FuelCard, FuelCardPolicy, FuelCardStationRule, FuelEntry, FuelStationCompany, FuelStationPoint, OrlenPrice, User, Vehicle
from ..services import POLISH_REGIONS, aggregate_ure_companies, calculate_costs, fetch_orlen_price, fetch_orlen_range, fetch_ure_stations, invoice_period
from ..station_catalog import sync_station_catalog
FUEL_TYPES = ("PB95", "PB98", "DIESEL", "LPG")
@api.get('/users')
@role_required('admin')
def users():
page=request.args.get('page',1,type=int);per=min(request.args.get('per_page',25,type=int),100);q=request.args.get('q','').strip();query=User.query
if q: query=query.filter(db.or_(User.name.ilike(f'%{q}%'),User.email.ilike(f'%{q}%')))
p=query.order_by(User.name).paginate(page=page,per_page=per,error_out=False);return response({'items':[user_dict(x) for x in p.items],'page':p.page,'pages':p.pages,'total':p.total})
@api.post('/users')
@role_required('boss','admin')
def create_user():
d=payload()
try:
if len(d.get('password',''))<8:raise ValueError('Hasło musi mieć co najmniej 8 znaków')
company_id=int(d.get('company_id') or current_user.company_id or CompanySettings.query.first().id)
if current_user.role=='boss' and company_id!=current_user.company_id:return fail('Brak uprawnień',403)
u=User(name=d['user_name'].strip(),email=d['email'].strip().lower(),role=d.get('role','user'),company_id=company_id);u.set_password(d['password']);db.session.add(u);db.session.commit();return response(user_dict(u),'Dodano użytkownika',201)
except Exception as exc:db.session.rollback();return fail(str(getattr(exc,'orig',exc)))
@api.put('/users/<int:user_id>')
@role_required('admin')
def update_user(user_id):
u=User.query.get_or_404(user_id);d=payload()
try:
u.name=d['name'].strip();u.email=d['email'].strip().lower();u.role=d['role'];u.active=boolv(d,'active');u.company_id=int(d.get('company_id') or u.company_id or CompanySettings.query.first().id);u.fuel_card_id=int(d['fuel_card_id']) if d.get('fuel_card_id') else None;pw=d.get('password','')
if pw:
if len(pw)<8:raise ValueError('Nowe hasło musi mieć co najmniej 8 znaków')
u.set_password(pw)
db.session.commit();return response(user_dict(u),'Zaktualizowano użytkownika')
except Exception as exc:db.session.rollback();return fail(str(getattr(exc,'orig',exc)))
@api.delete('/users/<int:user_id>')
@role_required('admin')
def delete_user(user_id):
user = User.query.get_or_404(user_id)
if user.id == current_user.id:
return fail('Nie możesz usunąć własnego konta administratora', 400)
if user.role == 'admin':
remaining_active_admins = User.query.filter(
User.role == 'admin', User.active.is_(True), User.id != user.id
).count()
if remaining_active_admins < 1:
return fail('Nie można usunąć ostatniego aktywnego administratora', 409)
if user.owned_vehicles:
return fail('Najpierw przypisz pojazdy tego użytkownika innemu właścicielowi', 409)
if FuelEntry.query.filter_by(user_id=user.id).first():
return fail('Użytkownik ma historię tankowań i nie może zostać usunięty. Możesz go dezaktywować.', 409)
user.shared_vehicles.clear()
user.favorite_stations.clear()
db.session.delete(user)
db.session.commit()
return response(message='Usunięto użytkownika')
+74
View File
@@ -0,0 +1,74 @@
from collections import defaultdict
from datetime import date, datetime
from decimal import Decimal
from flask import current_app, jsonify, request, url_for
from flask_login import current_user, login_required, login_user
from sqlalchemy import extract
from sqlalchemy.exc import IntegrityError
from . import api
from .common import accessible_vehicles, boolv, fail, payload, response, role_required, station_dict, user_dict, vehicle_dict
from .. import THEMES
from ..api_tokens import create_access_token
from ..extensions import db
from ..models import AppSetting, CompanySettings, FuelCard, FuelCardPolicy, FuelCardStationRule, FuelEntry, FuelStationCompany, FuelStationPoint, OrlenPrice, User, Vehicle
from ..services import POLISH_REGIONS, aggregate_ure_companies, calculate_costs, fetch_orlen_price, fetch_orlen_range, fetch_ure_stations, invoice_period
from ..station_catalog import sync_station_catalog
FUEL_TYPES = ("PB95", "PB98", "DIESEL", "LPG")
@api.get('/vehicles')
@login_required
def get_vehicles(): return response([vehicle_dict(v) for v in accessible_vehicles()])
@api.post('/vehicles')
@login_required
def create_vehicle():
d=payload()
try:
card_id=int(d['fuel_card_id']) if d.get('fuel_card_id') else None
owner_id=int(d.get('owner_id') or current_user.id);owner=db.session.get(User,owner_id)
if not owner: return fail('Nie znaleziono właściciela',404)
if current_user.role not in ('boss','admin') and owner.id!=current_user.id:return fail('Brak uprawnień',403)
if current_user.role=='boss' and owner.company_id!=current_user.company_id:return fail('Użytkownik jest w innej firmie',403)
v=Vehicle(name=d['name'].strip(),make=d['make'].strip(),model=d['model'].strip(),registration=d['registration'].strip().upper(),fuel_type=d['fuel_type'],owner_id=owner.id,company_id=owner.company_id,has_fuel_card=bool(card_id),fuel_card_id=card_id,current_odometer=int(d.get('current_odometer') or 0))
db.session.add(v); db.session.commit(); return response(vehicle_dict(v),'Dodano pojazd',201)
except (KeyError,ValueError,IntegrityError) as exc: db.session.rollback(); return fail(str(getattr(exc,'orig',exc)),400)
@api.put('/vehicles/<int:vehicle_id>/fuel-card')
@login_required
def assign_vehicle_fuel_card(vehicle_id):
v=Vehicle.query.get_or_404(vehicle_id)
if current_user.role not in ('boss','admin') and v.owner_id!=current_user.id:return fail('Brak uprawnień',403)
d=payload();card_id=int(d['fuel_card_id']) if d.get('fuel_card_id') else None
card=db.session.get(FuelCard,card_id) if card_id else None
if card_id and not card:return fail('Nie znaleziono karty paliwowej',404)
if card and v.company_id and card.company_id!=v.company_id:return fail('Karta należy do innej firmy',400)
v.fuel_card_id=card_id;v.has_fuel_card=bool(card_id);db.session.commit();return response(vehicle_dict(v),'Zapisano kartę pojazdu')
@api.post('/vehicles/<int:vehicle_id>/shares')
@login_required
def share_vehicle(vehicle_id):
v=Vehicle.query.get_or_404(vehicle_id)
if current_user.role!='admin' and v.owner_id!=current_user.id: return fail('Brak uprawnień',403)
d=payload(); u=User.query.filter_by(email=(d.get('email') or '').strip().lower()).first()
if not u:return fail('Nie znaleziono użytkownika',404)
if u not in v.drivers:v.drivers.append(u);db.session.commit();return response(vehicle_dict(v),'Udostępniono pojazd')
@api.delete('/vehicles/<int:vehicle_id>/shares/<int:user_id>')
@login_required
def unshare_vehicle(vehicle_id,user_id):
v=Vehicle.query.get_or_404(vehicle_id)
if current_user.role!='admin' and v.owner_id!=current_user.id:return fail('Brak uprawnień',403)
u=User.query.get_or_404(user_id)
if u in v.drivers:v.drivers.remove(u);db.session.commit();return response(vehicle_dict(v),'Odebrano dostęp')
@api.put('/vehicles/<int:vehicle_id>/card-policy')
@login_required
def card_policy(vehicle_id):
v=Vehicle.query.get_or_404(vehicle_id)
if current_user.role!='admin' and v.owner_id!=current_user.id:return fail('Brak uprawnień',403)
d=payload();p=v.fuel_card_policy or FuelCardPolicy(vehicle=v)
p.use_orlen_last_price=False;p.discount_percent=Decimal(d.get('discount_percent') or 0);p.surcharge_per_liter=Decimal(d.get('surcharge_per_liter') or 0);p.description=(d.get('description') or '').strip() or None
db.session.add(p);db.session.commit();return response(message='Zapisano zasady rozliczeń')
+48 -9
View File
@@ -1,19 +1,58 @@
from flask import Blueprint, render_template, request, redirect, url_for, flash from urllib.parse import urljoin, urlparse
from flask_login import login_user, logout_user, login_required from flask import Blueprint, flash, redirect, render_template, request, url_for
from flask_login import current_user, login_required, login_user, logout_user
from .models import User from .models import User
auth = Blueprint("auth", __name__) auth = Blueprint("auth", __name__)
def is_safe_redirect_url(target: str | None) -> bool:
if not target:
return False
host_url = urlparse(request.host_url)
redirect_url = urlparse(urljoin(request.host_url, target))
return (
redirect_url.scheme in {"http", "https"}
and redirect_url.netloc == host_url.netloc
)
@auth.route("/login", methods=["GET", "POST"]) @auth.route("/login", methods=["GET", "POST"])
def login(): def login():
if request.method == "POST": if current_user.is_authenticated:
user = User.query.filter_by(email=request.form["email"].strip().lower()).first() return redirect(url_for("main.dashboard"))
if user and user.check_password(request.form["password"]):
login_user(user); return redirect(url_for("main.dashboard")) if request.method == "GET":
flash("Nieprawidłowy e-mail lub hasło", "danger")
return render_template("login.html") return render_template("login.html")
@auth.route("/logout") email = request.form.get("email", "").strip().lower()
password = request.form.get("password", "")
remember = request.form.get("remember") == "on"
if not email or not password:
flash("Podaj adres e-mail i hasło.", "warning")
return render_template("login.html"), 400
user = User.query.filter_by(email=email).first()
if user is None or not user.check_password(password):
flash("Nieprawidłowy e-mail lub hasło.", "danger")
return render_template("login.html"), 401
login_user(user, remember=remember)
next_url = request.args.get("next")
if is_safe_redirect_url(next_url):
return redirect(next_url)
return redirect(url_for("main.dashboard"))
@auth.route("/logout", methods=["POST"])
@login_required @login_required
def logout(): def logout():
logout_user(); return redirect(url_for("auth.login")) logout_user()
flash("Zostałeś wylogowany.", "success")
return redirect(url_for("auth.login"))
+1 -2
View File
@@ -35,7 +35,7 @@ class Config:
SECRET_KEY = env("SECRET_KEY", "dev-secret") SECRET_KEY = env("SECRET_KEY", "dev-secret")
SQLALCHEMY_DATABASE_URI = env("DATABASE_URL", "sqlite:////app/instance/fueltrack.db") SQLALCHEMY_DATABASE_URI = env("DATABASE_URL", "sqlite:////app/instance/fueltrack.db")
SQLALCHEMY_TRACK_MODIFICATIONS = False SQLALCHEMY_TRACK_MODIFICATIONS = False
API_TOKEN_MAX_AGE_SECONDS = env_int("API_TOKEN_MAX_AGE_SECONDS", 28800) API_TOKEN_MAX_AGE_SECONDS = env_int("API_TOKEN_MAX_AGE_SECONDS", 928800)
HTTP_TIMEOUT_SECONDS = env_float("HTTP_TIMEOUT_SECONDS", 30) HTTP_TIMEOUT_SECONDS = env_float("HTTP_TIMEOUT_SECONDS", 30)
ORLEN_TIMEOUT_SECONDS = env_float("ORLEN_TIMEOUT_SECONDS", 15) ORLEN_TIMEOUT_SECONDS = env_float("ORLEN_TIMEOUT_SECONDS", 15)
@@ -50,7 +50,6 @@ class Config:
CHOICES_CSS_URL = env("CHOICES_CSS_URL", "https://cdn.jsdelivr.net/npm/choices.js@11.1.0/public/assets/styles/choices.min.css") CHOICES_CSS_URL = env("CHOICES_CSS_URL", "https://cdn.jsdelivr.net/npm/choices.js@11.1.0/public/assets/styles/choices.min.css")
CHOICES_JS_URL = env("CHOICES_JS_URL", "https://cdn.jsdelivr.net/npm/choices.js@11.1.0/public/assets/scripts/choices.min.js") CHOICES_JS_URL = env("CHOICES_JS_URL", "https://cdn.jsdelivr.net/npm/choices.js@11.1.0/public/assets/scripts/choices.min.js")
CHART_JS_URL = env("CHART_JS_URL", "https://cdn.jsdelivr.net/npm/chart.js@4.5.0/dist/chart.umd.min.js") CHART_JS_URL = env("CHART_JS_URL", "https://cdn.jsdelivr.net/npm/chart.js@4.5.0/dist/chart.umd.min.js")
SOCKET_IO_JS_URL = env("SOCKET_IO_JS_URL", "https://cdn.socket.io/4.8.1/socket.io.min.js")
THEME_BOOTSTRAP_URL = env("THEME_BOOTSTRAP_URL", "https://cdn.jsdelivr.net/npm/bootstrap@5.3.7/dist/css/bootstrap.min.css") THEME_BOOTSTRAP_URL = env("THEME_BOOTSTRAP_URL", "https://cdn.jsdelivr.net/npm/bootstrap@5.3.7/dist/css/bootstrap.min.css")
THEME_FLATLY_URL = env("THEME_FLATLY_URL", "https://cdn.jsdelivr.net/npm/bootswatch@5.3.7/dist/flatly/bootstrap.min.css") THEME_FLATLY_URL = env("THEME_FLATLY_URL", "https://cdn.jsdelivr.net/npm/bootswatch@5.3.7/dist/flatly/bootstrap.min.css")
+10 -4
View File
@@ -1,11 +1,8 @@
from __future__ import annotations from __future__ import annotations
import fcntl import fcntl
from contextlib import contextmanager from contextlib import contextmanager
from pathlib import Path from pathlib import Path
from flask import Flask from flask import Flask
from .database_init import initialize_database from .database_init import initialize_database
from .database_migrations import migrate_database from .database_migrations import migrate_database
@@ -26,7 +23,16 @@ def _startup_lock(app: Flask):
def prepare_database(app: Flask) -> list[str]: def prepare_database(app: Flask) -> list[str]:
with app.app_context(), _startup_lock(app): with app.app_context(), _startup_lock(app):
initialize_database() initialize_database()
return migrate_database() applied = migrate_database()
from .extensions import db
from .models import FuelEntry
from .settlement import freeze
pending = FuelEntry.query.filter_by(settlement_frozen=False).all()
for entry in pending:
freeze(entry, source="Migracja istniejącego wpisu", migration_note="Warunki odtworzono z ustawień dostępnych podczas migracji; wymagają weryfikacji.")
if pending:
db.session.commit()
return applied
__all__ = ["initialize_database", "migrate_database", "prepare_database"] __all__ = ["initialize_database", "migrate_database", "prepare_database"]
-2
View File
@@ -1,7 +1,5 @@
from __future__ import annotations from __future__ import annotations
from sqlalchemy import inspect from sqlalchemy import inspect
from .extensions import db from .extensions import db
from .models import AppSetting, CompanySettings, User from .models import AppSetting, CompanySettings, User
+70 -3
View File
@@ -1,10 +1,7 @@
from __future__ import annotations from __future__ import annotations
from collections.abc import Callable from collections.abc import Callable
from dataclasses import dataclass from dataclasses import dataclass
from sqlalchemy import inspect, text from sqlalchemy import inspect, text
from .extensions import db from .extensions import db
@@ -24,8 +21,78 @@ def add_station_access_mode() -> None:
)) ))
def add_fuel_entry_vat_action_snapshot() -> None:
inspector = inspect(db.engine)
columns = {column["name"] for column in inspector.get_columns("fuel_entry")}
additions = {
"vat_action_frozen": "BOOLEAN NOT NULL DEFAULT 0",
"vat_action_applied": "BOOLEAN NOT NULL DEFAULT 0",
"vat_action_name": "VARCHAR(160)",
"vat_action_rate": "NUMERIC(6, 3)",
"vat_action_start": "DATE",
"vat_action_end": "DATE",
}
for name, definition in additions.items():
if name not in columns:
db.session.execute(text(f"ALTER TABLE fuel_entry ADD COLUMN {name} {definition}"))
settings = dict(db.session.execute(text(
"SELECT key, value FROM app_setting WHERE key IN "
"('global_vat_override_enabled','global_vat_override_name','global_vat_override_rate',"
"'global_vat_override_start','global_vat_override_end')"
)).all())
if settings.get("global_vat_override_enabled") == "1" and settings.get("global_vat_override_rate"):
conditions = ["vat_action_applied = 0"]
params = {
"name": settings.get("global_vat_override_name") or "Projekt rządowy",
"rate": settings["global_vat_override_rate"],
"start": settings.get("global_vat_override_start") or None,
"end": settings.get("global_vat_override_end") or None,
}
if params["start"]:
conditions.append("DATE(fueled_at) >= DATE(:start)")
if params["end"]:
conditions.append("DATE(fueled_at) <= DATE(:end)")
db.session.execute(text(f"""
UPDATE fuel_entry
SET vat_action_frozen = 1,
vat_action_applied = 1,
vat_action_name = :name,
vat_action_rate = :rate,
vat_action_start = :start,
vat_action_end = :end
WHERE {' AND '.join(conditions)}
"""), params)
db.session.execute(text("UPDATE fuel_entry SET vat_action_frozen = 1 WHERE vat_action_frozen = 0"))
def add_fuel_entry_full_settlement_snapshot() -> None:
inspector = inspect(db.engine)
columns = {column["name"] for column in inspector.get_columns("fuel_entry")}
additions = {
"settlement_frozen": "BOOLEAN NOT NULL DEFAULT 0",
"snapshot_vat_rate": "NUMERIC(6, 3)", "snapshot_vat_deduction_percent": "NUMERIC(6, 3)",
"snapshot_uses_last_price": "BOOLEAN NOT NULL DEFAULT 0", "snapshot_discount_percent": "NUMERIC(6, 3)",
"snapshot_surcharge_per_liter": "NUMERIC(10, 4)", "snapshot_rule_source": "VARCHAR(160)",
"snapshot_base_net_price": "NUMERIC(12, 6)", "snapshot_settlement_net_price": "NUMERIC(12, 6)",
"result_retail_net_price": "NUMERIC(12, 6)", "result_invoice_price": "NUMERIC(12, 6)",
"result_effective_price": "NUMERIC(12, 6)", "result_retail_gross": "NUMERIC(14, 4)",
"result_retail_net_total": "NUMERIC(14, 4)", "result_settlement_net_total": "NUMERIC(14, 4)",
"result_invoice_gross": "NUMERIC(14, 4)", "result_deductible_vat": "NUMERIC(14, 4)",
"result_effective_cost": "NUMERIC(14, 4)", "result_saving_net": "NUMERIC(14, 4)",
"result_gross_saving": "NUMERIC(14, 4)", "settlement_snapshot_source": "VARCHAR(120)",
"settlement_migration_note": "VARCHAR(255)"
}
for name, definition in additions.items():
if name not in columns:
db.session.execute(text(f"ALTER TABLE fuel_entry ADD COLUMN {name} {definition}"))
MIGRATIONS: tuple[Migration, ...] = ( MIGRATIONS: tuple[Migration, ...] = (
Migration("20260713_company_station_access_mode", add_station_access_mode), Migration("20260713_company_station_access_mode", add_station_access_mode),
Migration("20260719_fuel_entry_vat_action_snapshot", add_fuel_entry_vat_action_snapshot),
Migration("20260720_fuel_entry_full_settlement_snapshot", add_fuel_entry_full_settlement_snapshot),
) )
def ensure_migration_table() -> None: def ensure_migration_table() -> None:
+44
View File
@@ -12,3 +12,47 @@ def calculate_costs(gross, vat_rate=23, vat_deduction_percent=50):
deductible_vat = vat * deduction deductible_vat = vat * deduction
return {"gross": money(gross), "net": money(net), "vat": money(vat), return {"gross": money(gross), "net": money(net), "vat": money(vat),
"deductible_vat": money(deductible_vat), "final_cost": money(gross - deductible_vat)} "deductible_vat": money(deductible_vat), "final_cost": money(gross - deductible_vat)}
def calculate_net_price_costs(net_price, vat_rate=23, vat_deduction_percent=50):
"""Return per-unit invoice and effective costs for a net fuel price.
A 50% VAT deduction means the company bears half of the VAT, not a 50% VAT rate.
"""
net = Decimal(str(net_price))
rate = Decimal(str(vat_rate)) / Decimal("100")
deduction = Decimal(str(vat_deduction_percent)) / Decimal("100")
deduction = min(max(deduction, Decimal("0")), Decimal("1"))
vat = net * rate
invoice_gross = net + vat
deductible_vat = vat * deduction
effective_cost = invoice_gross - deductible_vat
return {
"net": net,
"vat": vat,
"invoice_gross": invoice_gross,
"deductible_vat": deductible_vat,
"effective_cost": effective_cost,
}
def calculate_net_price_comparison(retail_gross_price, settlement_net_price, liters=1, vat_rate=23):
"""Compare retail and settlement prices on a net-to-net basis.
Retail price is supplied gross (pump price), while settlement/Last Price is net.
VAT is excluded from the saving because recoverable tax is not a commercial saving.
"""
retail_gross = Decimal(str(retail_gross_price))
settlement_net = Decimal(str(settlement_net_price))
quantity = Decimal(str(liters))
rate = Decimal(str(vat_rate)) / Decimal("100")
retail_net = retail_gross / (Decimal("1") + rate)
saving_per_liter = max(retail_net - settlement_net, Decimal("0"))
return {
"retail_net_price": retail_net,
"settlement_net_price": settlement_net,
"saving_net_per_liter": saving_per_liter,
"retail_net_total": retail_net * quantity,
"settlement_net_total": settlement_net * quantity,
"saving_net_total": saving_per_liter * quantity,
}
-2
View File
@@ -1,8 +1,6 @@
from flask_sqlalchemy import SQLAlchemy from flask_sqlalchemy import SQLAlchemy
from flask_login import LoginManager from flask_login import LoginManager
from flask_socketio import SocketIO
db = SQLAlchemy() db = SQLAlchemy()
login_manager = LoginManager() login_manager = LoginManager()
login_manager.login_view = "auth.login" login_manager.login_view = "auth.login"
socketio = SocketIO(async_mode="threading", cors_allowed_origins="*", ping_timeout=60, ping_interval=25)
+492 -57
View File
@@ -4,14 +4,16 @@ 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 from sqlalchemy import extract, or_, and_
from sqlalchemy.exc import IntegrityError from sqlalchemy.exc import IntegrityError
from .extensions import db, socketio 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
from .services import calculate_costs, fetch_orlen_price, fetch_orlen_range, invoice_period, fetch_ure_stations, aggregate_ure_companies, POLISH_REGIONS from .services import calculate_costs, fetch_orlen_price, fetch_orlen_range, invoice_period, fetch_ure_stations, aggregate_ure_companies, POLISH_REGIONS
from .station_catalog import sync_station_catalog from .station_catalog import sync_station_catalog
from . import THEMES from . import THEMES
from .vat import effective_vat_rate, global_vat_override, save_global_vat_override from .vat import effective_vat_rate, entry_vat_rate, freeze_vat_action, global_vat_override, save_global_vat_override
from .settlement import calculate as calculate_snapshot_settlement, freeze as freeze_settlement
from .orlen_sync import sync_orlen_prices
main = Blueprint("main", __name__) main = Blueprint("main", __name__)
FUEL_TYPES = ("PB95", "PB98", "DIESEL", "LPG") FUEL_TYPES = ("PB95", "PB98", "DIESEL", "LPG")
@@ -21,7 +23,6 @@ def wants_json():
return request.headers.get("X-Requested-With") == "XMLHttpRequest" or request.is_json return request.headers.get("X-Requested-With") == "XMLHttpRequest" or request.is_json
def ok_response(message, **extra): def ok_response(message, **extra):
socketio.emit("app_changed", {"message": message})
if wants_json(): if wants_json():
return jsonify({"ok": True, "message": message, **extra}) return jsonify({"ok": True, "message": message, **extra})
flash(message, "success") flash(message, "success")
@@ -44,6 +45,133 @@ 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")
previous_fueled_at = entry.fueled_at
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
settings = vehicle.company or (vehicle.owner.company if vehicle.owner else None) or CompanySettings.query.first()
# During manual editing the action snapshot is authoritative. This prevents
# later global actions from changing historical settlements.
if "vat_action_applied" in request.form or any(
key in request.form for key in ("vat_action_name", "vat_action_rate", "vat_action_start", "vat_action_end")
):
applied = "vat_action_applied" in request.form
entry.vat_action_frozen = True
entry.vat_action_applied = applied
if applied:
action_name = (request.form.get("vat_action_name") or "").strip()
action_rate_raw = (request.form.get("vat_action_rate") or "").strip()
if not action_name:
raise ValueError("Podaj nazwę akcji")
if not action_rate_raw:
raise ValueError("Podaj stawkę VAT akcji")
action_rate = Decimal(action_rate_raw)
if action_rate < 0 or action_rate > 100:
raise ValueError("Stawka VAT akcji musi mieścić się w zakresie 0100%")
start_raw = (request.form.get("vat_action_start") or "").strip()
end_raw = (request.form.get("vat_action_end") or "").strip()
action_start = date.fromisoformat(start_raw) if start_raw else None
action_end = date.fromisoformat(end_raw) if end_raw else None
if action_start and action_end and action_start > action_end:
raise ValueError("Data zakończenia akcji nie może być wcześniejsza niż data rozpoczęcia")
fuel_day = fueled_at.date()
if action_start and fuel_day < action_start:
raise ValueError("Data tankowania jest wcześniejsza niż początek akcji")
if action_end and fuel_day > action_end:
raise ValueError("Data tankowania jest późniejsza niż koniec akcji")
entry.vat_action_name = action_name
entry.vat_action_rate = action_rate
entry.vat_action_start = action_start
entry.vat_action_end = action_end
else:
entry.vat_action_name = None
entry.vat_action_rate = None
entry.vat_action_start = None
entry.vat_action_end = None
else:
freeze_vat_action(entry, settings.vat_rate, previous_fueled_at)
manual_keys = ("snapshot_vat_rate", "snapshot_vat_deduction_percent", "snapshot_discount_percent", "snapshot_surcharge_per_liter", "snapshot_uses_last_price")
if any(key in request.form for key in manual_keys):
vat_rate = Decimal(request.form.get("snapshot_vat_rate", ""))
deduction = Decimal(request.form.get("snapshot_vat_deduction_percent", ""))
discount = Decimal(request.form.get("snapshot_discount_percent", "0") or 0)
surcharge = Decimal(request.form.get("snapshot_surcharge_per_liter", "0") or 0)
if not (0 <= vat_rate <= 100 and 0 <= deduction <= 100 and 0 <= discount <= 100):
raise ValueError("Stawki VAT, odliczenia i rabatu muszą mieścić się w zakresie 0100%")
if entry.vat_action_applied and entry.vat_action_rate is not None and vat_rate != Decimal(entry.vat_action_rate):
raise ValueError("Stawka VAT rozliczenia musi być zgodna ze stawką zapisanej akcji")
uses_last = "snapshot_uses_last_price" in request.form
if uses_last and entry.wholesale_price is None:
raise ValueError("Last Price wymaga zapisanej ceny hurtowej")
conditions = {"vat_rate": vat_rate, "vat_deduction_percent": deduction, "uses_last_price": uses_last,
"discount_percent": discount, "surcharge_per_liter": surcharge,
"rule_source": (request.form.get("snapshot_rule_source") or "Ręczna korekta").strip()}
freeze_settlement(entry, settings, conditions, source="Ręczna korekta w edycji")
else:
freeze_settlement(entry, settings, source="Automatycznie przy zapisie/edycji")
return vehicle
def calculate_entry_settlement(entry, fallback_settings=None):
# Historyczne wyniki po zamrożeniu korzystają wyłącznie z migawki wpisu.
if not entry.settlement_frozen:
freeze_settlement(entry, fallback_settings, source="Migracja istniejącego wpisu", migration_note="Warunki odtworzono z ustawień dostępnych podczas migracji; wymagają weryfikacji.")
return calculate_snapshot_settlement(entry, fallback_settings)
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()
@@ -61,32 +189,40 @@ def dashboard():
try: year, mon = map(int, month.split("-")) try: year, mon = map(int, month.split("-"))
except ValueError: year, mon = datetime.now().year, datetime.now().month; month=f"{year:04d}-{mon:02d}" 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 [] 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")} zero = Decimal("0")
by_vehicle = defaultdict(lambda: {"gross": 0, "payable": 0, "liters": 0}); invoices = defaultdict(lambda: {"gross": 0, "payable": 0, "count": 0}); settlements = {} totals = {"retail_gross": zero, "retail_net": zero, "settlement_net": zero, "invoice_gross": zero, "gross_saving": zero, "deductible_vat": zero, "final_cost": zero, "liters": zero, "saving_net": zero, "saving_base_net": zero}
by_vehicle = defaultdict(lambda: {"retail_gross": 0, "invoice_gross": 0, "gross_saving": 0, "retail_net": 0, "settlement_net": 0, "saving_net": 0, "effective": 0, "liters": 0})
invoices = defaultdict(lambda: {"gross": 0, "payable": 0, "count": 0}); settlements = {}
for e in entries: for e in entries:
vat_rate = effective_vat_rate(settings.vat_rate, e.fueled_at) row = calculate_entry_settlement(e, settings)
c = calculate_costs(e.gross, vat_rate, settings.vat_deduction_percent) totals["retail_gross"] += row["retail_gross"]
totals["gross"] += c["gross"]; totals["deductible_vat"] += c["deductible_vat"]; totals["final_cost"] += c["final_cost"]; totals["liters"] += Decimal(e.liters) totals["retail_net"] += row["retail_net_total"]
station_policy = e.station_company totals["settlement_net"] += row["settlement_net_total"]
normal_price = float(e.price_per_liter) totals["invoice_gross"] += row["invoice_gross"]
payable_price = normal_price totals["gross_saving"] += row["gross_saving"]
rule = FuelCardStationRule.query.filter_by(fuel_card_id=e.fuel_card_id, station_company_id=e.station_company_id).first() if e.used_fuel_card and e.fuel_card_id and e.station_company_id else None totals["deductible_vat"] += row["deductible_vat"]
uses_station_last_price = bool(rule and rule.use_orlen_last_price and e.wholesale_price is not None) totals["final_cost"] += row["effective_cost"]
if uses_station_last_price: totals["liters"] += row["liters"]
payable_price = float(e.wholesale_price) * (1 + float(vat_rate) / 100) totals["saving_net"] += row["saving_net"]
if rule: if row["uses_last_price"]:
net = payable_price / (1 + float(vat_rate) / 100) totals["saving_base_net"] += row["retail_net_total"]
net = net * (1 - float(rule.discount_net_percent or 0) / 100) + float(rule.surcharge_net_per_liter or 0) settlements[e.id] = {k: float(v) if isinstance(v, Decimal) else v for k, v in row.items()}
payable_price = net * (1 + float(vat_rate) / 100) bucket = by_vehicle[e.vehicle.name]
payable_price = max(payable_price, 0) bucket["retail_gross"] += float(row["retail_gross"])
payable_gross = float(e.liters) * payable_price bucket["retail_net"] += float(row["retail_net_total"])
settlements[e.id] = {"normal_price": normal_price, "payable_price": payable_price, "normal_gross": e.gross, "payable_gross": payable_gross, "uses_last_price": uses_station_last_price, "vat_rate": float(vat_rate)} bucket["settlement_net"] += float(row["settlement_net_total"])
by_vehicle[e.vehicle.name]["gross"] += float(c["gross"]); by_vehicle[e.vehicle.name]["payable"] += payable_gross; by_vehicle[e.vehicle.name]["liters"] += float(e.liters) bucket["saving_net"] += float(row["saving_net"])
bucket["invoice_gross"] += float(row["invoice_gross"])
bucket["gross_saving"] += float(row["gross_saving"])
bucket["effective"] += float(row["effective_cost"])
bucket["liters"] += float(row["liters"])
entry_settings = e.vehicle.company or settings
if e.used_fuel_card: if e.used_fuel_card:
key = invoice_period(e.fueled_at, settings.invoice_split_day) if split_enabled else "Cały miesiąc" key = invoice_period(e.fueled_at, entry_settings.invoice_split_day) if split_enabled else "Cały miesiąc"
else: else:
key = "Poza kartą" key = "Poza kartą"
invoices[key]["gross"] += float(c["gross"]); invoices[key]["payable"] += payable_gross; invoices[key]["count"] += 1 invoices[key]["gross"] += float(row["invoice_gross"]); invoices[key]["payable"] += float(row["effective_cost"]); invoices[key]["count"] += 1
totals["saving_percent"] = (totals["saving_net"] / totals["saving_base_net"] * Decimal("100")) if totals["saving_base_net"] else zero
return render_template("dashboard.html", vehicles=vehicles, entries=entries, totals=totals, month=month, by_vehicle=dict(by_vehicle), invoices=dict(invoices), settings=settings, split_enabled=split_enabled, settlements=settlements, vat_override=global_vat_override()) return render_template("dashboard.html", vehicles=vehicles, entries=entries, totals=totals, month=month, by_vehicle=dict(by_vehicle), invoices=dict(invoices), settings=settings, split_enabled=split_enabled, settlements=settlements, vat_override=global_vat_override())
@main.route("/vehicles", methods=["GET", "POST"]) @main.route("/vehicles", methods=["GET", "POST"])
@@ -173,8 +309,20 @@ def fuel():
p = fetch_orlen_price(request.form["fuel_type"], fueled_at.date(), price_region); wholesale=p["price_per_liter"]; source=p["source"] p = fetch_orlen_price(request.form["fuel_type"], fueled_at.date(), price_region); wholesale=p["price_per_liter"]; source=p["source"]
except Exception: pass except Exception: pass
station_label = station_company.company_name if station_company else request.form.get("station") station_label = station_company.company_name if station_company else request.form.get("station")
e = FuelEntry(vehicle_id=vehicle.id, user_id=current_user.id, fueled_at=fueled_at, liters=Decimal(request.form["liters"]), price_per_liter=Decimal(request.form["price_per_liter"]), fuel_type=request.form["fuel_type"], odometer=odometer, station=station_label, station_company_id=station_company.id if station_company else None, invoice_number=request.form.get("invoice_number"), used_fuel_card="used_fuel_card" in request.form, wholesale_price=wholesale, wholesale_source=source) requested_card_id = request.form.get("fuel_card_id", type=int)
vehicle.current_odometer = odometer; db.session.add(e); db.session.commit(); socketio.emit("fuel_added", {"vehicle": vehicle.name, "gross": round(e.gross,2)}); response = ok_response("Zapisano tankowanie", redirect=url_for("main.dashboard")); return response or redirect(url_for("main.dashboard")) default_card_id = vehicle.fuel_card_id or current_user.fuel_card_id
fuel_card = db.session.get(FuelCard, requested_card_id or default_card_id) if (requested_card_id or default_card_id) else None
used_fuel_card = "used_fuel_card" in request.form and fuel_card is not None
if fuel_card and fuel_card.company_id and fuel_card.company_id != (vehicle.company_id or vehicle.owner.company_id):
response = error_response("Wybrana karta nie należy do firmy pojazdu"); return response or redirect(url_for("main.fuel"))
if used_fuel_card and station_company:
card_rule = FuelCardStationRule.query.filter_by(fuel_card_id=fuel_card.id, station_company_id=station_company.id).first()
if card_rule and not card_rule.allowed:
response = error_response("Ta stacja jest zablokowana dla wybranej karty paliwowej", 403); return response or redirect(url_for("main.fuel"))
e = FuelEntry(vehicle=vehicle, user_id=current_user.id, fueled_at=fueled_at, liters=Decimal(request.form["liters"]), price_per_liter=Decimal(request.form["price_per_liter"]), fuel_type=request.form["fuel_type"], odometer=odometer, station=station_label, station_company_id=station_company.id if station_company else None, fuel_card_id=fuel_card.id if used_fuel_card else None, invoice_number=request.form.get("invoice_number"), used_fuel_card=used_fuel_card, wholesale_price=wholesale, wholesale_source=source)
freeze_vat_action(e, settings.vat_rate)
freeze_settlement(e, settings, source="Automatycznie przy dodaniu")
vehicle.current_odometer = odometer; db.session.add(e); db.session.commit(); response = ok_response("Zapisano tankowanie", redirect=url_for("main.dashboard")); return response or redirect(url_for("main.dashboard"))
allowed=list(settings.allowed_stations) allowed=list(settings.allowed_stations)
company_favorites=list(settings.favorite_stations) company_favorites=list(settings.favorite_stations)
access_mode=settings.station_access_mode or "all_prefer_favorites" access_mode=settings.station_access_mode or "all_prefer_favorites"
@@ -213,52 +361,339 @@ def orlen_price():
@login_required @login_required
def orlen_data(): def orlen_data():
settings = CompanySettings.query.first() settings = CompanySettings.query.first()
selected_year = request.args.get("year", type=int) or date.today().year today = date.today()
selected_fuels = [f.upper() for f in request.args.getlist("fuel") if f.upper() in FUEL_TYPES] or ["PB95"] mode = request.args.get("mode", "history")
if mode not in {"history", "compare"}:
mode = "history"
include_vat = request.args.get("gross") == "1" 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] 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 selected_regions:
if not show_lpg_regions: selected_regions=[settings.region.lower()] selected_regions = [settings.region.lower()]
rows = []
chart = {}
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:
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) 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() rows = query.order_by(OrlenPrice.effective_date, OrlenPrice.fuel_type).all()
chart = {}
for fuel in selected_fuels: 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)] fuel_rows = [row for row in rows if row.fuel_type == fuel and (fuel != "LPG" or row.region in selected_regions)]
chart_key = fuel if fuel != "LPG" else None
if fuel == "LPG": if fuel == "LPG":
for region in selected_regions: 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] chart[f"LPG · {region}"] = [{"date": row.effective_date.isoformat(), "value": priced_value(row)} for row in fuel_rows if row.region == region]
else: 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] chart[fuel] = [{"date": row.effective_date.isoformat(), "value": priced_value(row)} for row 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) 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") @main.post("/orlen/sync")
@login_required @login_required
@roles("boss", "admin") @roles("boss", "admin")
def orlen_sync(): def orlen_sync():
payload = request.get_json(silent=True) or request.form data = request.get_json(silent=True) or request.form
fuels = payload.get("fuels", []) fuels = data.get("fuels", [])
if isinstance(fuels, str): fuels = [fuels] if isinstance(fuels, str):
fuels = [f.upper() for f in fuels if f.upper() in FUEL_TYPES] fuels = [fuels]
year = int(payload.get("year", date.today().year)) fuels = [fuel.upper() for fuel in fuels if fuel.upper() in FUEL_TYPES]
if not fuels: return jsonify({"ok": False, "error": "Wybierz co najmniej jedno paliwo."}), 400 if not fuels:
settings = CompanySettings.query.first(); start = date(year,1,1); end = min(date(year,12,31), date.today()) return jsonify({"ok": False, "error": "Wybierz co najmniej jedno paliwo."}), 400
result = {}; total = 0
regions = data.get("regions", [])
if isinstance(regions, str):
regions = [regions]
regions = [str(region).strip().lower() for region in regions if str(region).strip().lower() in POLISH_REGIONS]
full_refresh = data.get("full_refresh") in (True, 1, "1", "true", "on", "yes")
try: try:
for fuel in fuels: settings = CompanySettings.query.first()
imported = 0; updated = 0 result, total = sync_orlen_prices(
for row in fetch_orlen_range(fuel, start, end, "all" if fuel == "LPG" else settings.region): fuels=fuels,
existing = OrlenPrice.query.filter_by(fuel_type=row["fuel_type"], effective_date=row["effective_date"], region=row["region"]).first() year=int(data.get("year", date.today().year)),
if existing: regions=regions,
existing.price_per_liter=row["price_per_liter"]; existing.raw_value=row["raw_value"]; existing.product_name=row["product_name"]; existing.source=row["source"]; existing.fetched_at=datetime.utcnow(); updated += 1 default_region=settings.region,
else: full_refresh=full_refresh,
db.session.add(OrlenPrice(**row)); imported += 1 )
result[fuel] = {"added": imported, "updated": updated}; total += imported + updated
db.session.commit() db.session.commit()
return jsonify({"ok": True, "message": f"Przetworzono {total} rekordów.", "result": result}) return jsonify({"ok": True, "message": f"Przetworzono {total} rekordów.", "result": result})
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/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_gross": zero, "retail_net": zero, "settlement_net": zero, "invoice": zero, "gross_saving": zero, "effective": zero, "vat_deducted": zero, "saving_net": zero, "saving_base_net": zero, "liters": zero, "count": 0, "last_price_count": 0}
by_month = defaultdict(lambda: {"retail_gross": 0.0, "invoice": 0.0, "gross_saving": 0.0, "retail_net": 0.0, "settlement_net": 0.0, "effective": 0.0, "saving_net": 0.0, "liters": 0.0, "count": 0})
by_vehicle = defaultdict(lambda: {"retail_gross": 0.0, "invoice": 0.0, "gross_saving": 0.0, "retail_net": 0.0, "settlement_net": 0.0, "effective": 0.0, "saving_net": 0.0, "liters": 0.0, "count": 0})
by_company = defaultdict(lambda: {"retail_gross": 0.0, "invoice": 0.0, "gross_saving": 0.0, "retail_net": 0.0, "settlement_net": 0.0, "effective": 0.0, "saving_net": 0.0, "liters": 0.0, "count": 0})
for entry in entries:
row = calculate_entry_settlement(entry)
totals["retail_gross"] += row["retail_gross"]
totals["retail_net"] += row["retail_net_total"]
totals["settlement_net"] += row["settlement_net_total"]
totals["invoice"] += row["invoice_gross"]
totals["gross_saving"] += row["gross_saving"]
totals["effective"] += row["effective_cost"]
totals["vat_deducted"] += row["deductible_vat"]
totals["saving_net"] += row["saving_net"]
if row["uses_last_price"]:
totals["saving_base_net"] += row["retail_net_total"]
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_gross"] += float(row["retail_gross"])
bucket[key]["retail_net"] += float(row["retail_net_total"])
bucket[key]["settlement_net"] += float(row["settlement_net_total"])
bucket[key]["invoice"] += float(row["invoice_gross"])
bucket[key]["gross_saving"] += float(row["gross_saving"])
bucket[key]["effective"] += float(row["effective_cost"])
bucket[key]["saving_net"] += float(row["saving_net"])
bucket[key]["liters"] += float(row["liters"])
bucket[key]["count"] += 1
totals["saving_percent"] = (totals["saving_net"] / totals["saving_base_net"] * Decimal("100")) if totals["saving_base_net"] 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")
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
+28
View File
@@ -171,6 +171,34 @@ class FuelEntry(db.Model):
used_fuel_card = db.Column(db.Boolean, default=False, nullable=False) used_fuel_card = db.Column(db.Boolean, default=False, nullable=False)
wholesale_price = db.Column(db.Numeric(10, 4)) wholesale_price = db.Column(db.Numeric(10, 4))
wholesale_source = db.Column(db.String(120)) wholesale_source = db.Column(db.String(120))
vat_action_frozen = db.Column(db.Boolean, default=False, nullable=False)
vat_action_applied = db.Column(db.Boolean, default=False, nullable=False)
vat_action_name = db.Column(db.String(160))
vat_action_rate = db.Column(db.Numeric(6, 3))
vat_action_start = db.Column(db.Date)
vat_action_end = db.Column(db.Date)
settlement_frozen = db.Column(db.Boolean, default=False, nullable=False)
snapshot_vat_rate = db.Column(db.Numeric(6, 3))
snapshot_vat_deduction_percent = db.Column(db.Numeric(6, 3))
snapshot_uses_last_price = db.Column(db.Boolean, default=False, nullable=False)
snapshot_discount_percent = db.Column(db.Numeric(6, 3))
snapshot_surcharge_per_liter = db.Column(db.Numeric(10, 4))
snapshot_rule_source = db.Column(db.String(160))
snapshot_base_net_price = db.Column(db.Numeric(12, 6))
snapshot_settlement_net_price = db.Column(db.Numeric(12, 6))
result_retail_net_price = db.Column(db.Numeric(12, 6))
result_invoice_price = db.Column(db.Numeric(12, 6))
result_effective_price = db.Column(db.Numeric(12, 6))
result_retail_gross = db.Column(db.Numeric(14, 4))
result_retail_net_total = db.Column(db.Numeric(14, 4))
result_settlement_net_total = db.Column(db.Numeric(14, 4))
result_invoice_gross = db.Column(db.Numeric(14, 4))
result_deductible_vat = db.Column(db.Numeric(14, 4))
result_effective_cost = db.Column(db.Numeric(14, 4))
result_saving_net = db.Column(db.Numeric(14, 4))
result_gross_saving = db.Column(db.Numeric(14, 4))
settlement_snapshot_source = db.Column(db.String(120))
settlement_migration_note = db.Column(db.String(255))
created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False) created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False)
vehicle = db.relationship("Vehicle", back_populates="fuel_entries") vehicle = db.relationship("Vehicle", back_populates="fuel_entries")
user = db.relationship("User") user = db.relationship("User")
-1
View File
@@ -19,7 +19,6 @@ def spec():
'paths':{ 'paths':{
'/api/auth/login':{'post':{'tags':['Auth'],'security':[],'summary':'Logowanie','requestBody':{'required':True,'content':{'application/json':{'schema':{'type':'object','required':['email','password'],'properties':{'email':{'type':'string'},'password':{'type':'string'}}}}}},'responses':{'200':{'description':'Zalogowano'},'401':{'description':'Błędne dane'}}}}, '/api/auth/login':{'post':{'tags':['Auth'],'security':[],'summary':'Logowanie','requestBody':{'required':True,'content':{'application/json':{'schema':{'type':'object','required':['email','password'],'properties':{'email':{'type':'string'},'password':{'type':'string'}}}}}},'responses':{'200':{'description':'Zalogowano'},'401':{'description':'Błędne dane'}}}},
'/api/auth/token':{'post':{'tags':['Auth'],'security':[],'summary':'Pobranie tokenu Bearer','description':'Endpoint zgodny z OAuth2 Password używany przez przycisk Authorize w Swagger UI. W polu username wpisz adres e-mail.','requestBody':{'required':True,'content':{'application/x-www-form-urlencoded':{'schema':{'type':'object','required':['username','password'],'properties':{'username':{'type':'string','format':'email','description':'Adres e-mail'},'password':{'type':'string','format':'password'}}}},'application/json':{'schema':{'type':'object','required':['email','password'],'properties':{'email':{'type':'string','format':'email'},'password':{'type':'string','format':'password'}}}}}},'responses':{'200':{'description':'Token wydany','content':{'application/json':{'schema':{'type':'object','properties':{'access_token':{'type':'string'},'token_type':{'type':'string','example':'Bearer'},'expires_in':{'type':'integer'}}}}}},'401':{'description':'Błędne dane logowania'}}}}, '/api/auth/token':{'post':{'tags':['Auth'],'security':[],'summary':'Pobranie tokenu Bearer','description':'Endpoint zgodny z OAuth2 Password używany przez przycisk Authorize w Swagger UI. W polu username wpisz adres e-mail.','requestBody':{'required':True,'content':{'application/x-www-form-urlencoded':{'schema':{'type':'object','required':['username','password'],'properties':{'username':{'type':'string','format':'email','description':'Adres e-mail'},'password':{'type':'string','format':'password'}}}},'application/json':{'schema':{'type':'object','required':['email','password'],'properties':{'email':{'type':'string','format':'email'},'password':{'type':'string','format':'password'}}}}}},'responses':{'200':{'description':'Token wydany','content':{'application/json':{'schema':{'type':'object','properties':{'access_token':{'type':'string'},'token_type':{'type':'string','example':'Bearer'},'expires_in':{'type':'integer'}}}}}},'401':{'description':'Błędne dane logowania'}}}},
'/api/auth/logout':{'post':{'tags':['Auth'],'summary':'Wylogowanie','responses':{'200':{'description':'Wylogowano'}}}},
'/api/me':{'get':{'tags':['Auth'],'summary':'Bieżący użytkownik','responses':{'200':{'description':'Użytkownik'}}}}, '/api/me':{'get':{'tags':['Auth'],'summary':'Bieżący użytkownik','responses':{'200':{'description':'Użytkownik'}}}},
'/api/vehicles':{'get':{'tags':['Vehicles'],'summary':'Lista dostępnych pojazdów','responses':{'200':{'description':'Lista'}}},'post':{'tags':['Vehicles'],'summary':'Dodanie pojazdu','responses':{'201':{'description':'Utworzono'}}}}, '/api/vehicles':{'get':{'tags':['Vehicles'],'summary':'Lista dostępnych pojazdów','responses':{'200':{'description':'Lista'}}},'post':{'tags':['Vehicles'],'summary':'Dodanie pojazdu','responses':{'201':{'description':'Utworzono'}}}},
'/api/vehicles/{vehicle_id}/shares':{'post':{'tags':['Vehicles'],'summary':'Udostępnienie pojazdu','parameters':[{'in':'path','name':'vehicle_id','required':True,'schema':{'type':'integer'}}],'responses':{'200':{'description':'Udostępniono'}}}}, '/api/vehicles/{vehicle_id}/shares':{'post':{'tags':['Vehicles'],'summary':'Udostępnienie pojazdu','parameters':[{'in':'path','name':'vehicle_id','required':True,'schema':{'type':'integer'}}],'responses':{'200':{'description':'Udostępniono'}}}},
+91
View File
@@ -0,0 +1,91 @@
from datetime import date, datetime
from sqlalchemy import func
from .extensions import db
from .models import OrlenPrice
from .services import fetch_orlen_range
def _year_bounds(year, today=None):
today = today or date.today()
start = date(year, 1, 1)
end = min(date(year, 12, 31), today)
if end < start:
raise ValueError("Nie można synchronizować danych dla przyszłego roku")
return start, end
def _latest_date(fuel, start, end, region=""):
query = db.session.query(func.max(OrlenPrice.effective_date)).filter(
OrlenPrice.fuel_type == fuel,
OrlenPrice.effective_date.between(start, end),
)
if fuel == "LPG":
query = query.filter(OrlenPrice.region == region)
else:
query = query.filter(OrlenPrice.region == "")
return query.scalar()
def _sync_start(fuel, start, end, regions, full_refresh):
if full_refresh:
return start
if fuel != "LPG":
return _latest_date(fuel, start, end) or start
latest_dates = [_latest_date("LPG", start, end, region) for region in regions]
return min((value or start) for value in latest_dates)
def sync_orlen_prices(fuels, year, regions, default_region, full_refresh=False, today=None):
start, end = _year_bounds(year, today=today)
target_regions = [str(region).strip().lower() for region in regions if str(region).strip()]
if not target_regions:
target_regions = [str(default_region).strip().lower()]
result = {}
total = 0
for fuel in fuels:
fuel_regions = target_regions if fuel == "LPG" else []
date_from = _sync_start(fuel, start, end, fuel_regions, full_refresh)
added = 0
updated = 0
rows = fetch_orlen_range(
fuel,
date_from,
end,
"all" if fuel == "LPG" else default_region,
)
for row in rows:
if fuel == "LPG" and row.get("region") not in fuel_regions:
continue
existing = OrlenPrice.query.filter_by(
fuel_type=row["fuel_type"],
effective_date=row["effective_date"],
region=row["region"],
).first()
if existing:
for key, value in row.items():
setattr(existing, key, value)
existing.fetched_at = datetime.utcnow()
updated += 1
else:
db.session.add(OrlenPrice(**row))
added += 1
processed = added + updated
result[fuel] = {
"added": added,
"updated": updated,
"from": date_from.isoformat(),
"to": end.isoformat(),
"full_refresh": bool(full_refresh),
}
total += processed
return result, total
-1
View File
@@ -1,4 +1,3 @@
"""Compatibility facade. New code lives in domain/ and integrations/."""
from .domain.costs import calculate_costs, money from .domain.costs import calculate_costs, money
from .domain.invoices import invoice_period from .domain.invoices import invoice_period
from .integrations.orlen import fetch_orlen_price, fetch_orlen_range, POLISH_REGIONS, PRODUCT_IDS from .integrations.orlen import fetch_orlen_price, fetch_orlen_range, POLISH_REGIONS, PRODUCT_IDS
+77
View File
@@ -0,0 +1,77 @@
from decimal import Decimal
from .models import CompanySettings, FuelCardStationRule
from .domain.costs import calculate_net_price_costs, calculate_net_price_comparison
from .vat import entry_vat_rate
D0 = Decimal('0')
def _d(value, default='0'):
return Decimal(str(value if value is not None else default))
def live_conditions(entry, fallback_settings=None):
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 = _d(entry_vat_rate(entry, settings.vat_rate))
deduction = _d(settings.vat_deduction_percent)
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
policy = entry.vehicle.fuel_card_policy if entry.used_fuel_card else None
uses_last = bool(entry.wholesale_price is not None and ((rule and rule.use_orlen_last_price) or (not rule and policy and policy.use_orlen_last_price)))
if rule:
discount, surcharge, source = _d(rule.discount_net_percent), _d(rule.surcharge_net_per_liter), 'Reguła karty dla stacji'
elif policy:
discount, surcharge, source = _d(policy.discount_percent), _d(policy.surcharge_per_liter), 'Polityka karty pojazdu'
else:
discount, surcharge, source = D0, D0, 'Brak rabatu/dopłaty'
return {'vat_rate': vat_rate, 'vat_deduction_percent': deduction, 'uses_last_price': uses_last,
'discount_percent': max(D0, min(discount, Decimal('100'))), 'surcharge_per_liter': surcharge,
'rule_source': source}
def snapshot_conditions(entry, fallback_settings=None):
if entry.settlement_frozen:
return {'vat_rate': _d(entry.snapshot_vat_rate), 'vat_deduction_percent': _d(entry.snapshot_vat_deduction_percent),
'uses_last_price': bool(entry.snapshot_uses_last_price), 'discount_percent': _d(entry.snapshot_discount_percent),
'surcharge_per_liter': _d(entry.snapshot_surcharge_per_liter), 'rule_source': entry.snapshot_rule_source or 'Migawka'}
return live_conditions(entry, fallback_settings)
def calculate(entry, fallback_settings=None, conditions=None):
c = conditions or snapshot_conditions(entry, fallback_settings)
vat, deduction = c['vat_rate'], c['vat_deduction_percent']
retail, liters = _d(entry.price_per_liter), _d(entry.liters)
uses_last = bool(c['uses_last_price'] and entry.wholesale_price is not None)
base_net = _d(entry.wholesale_price) if uses_last else retail / (Decimal('1') + vat / Decimal('100'))
settlement_net = max(D0, base_net * (Decimal('1') - c['discount_percent'] / Decimal('100')) + c['surcharge_per_liter'])
costs = calculate_net_price_costs(settlement_net, vat, deduction)
comparison = calculate_net_price_comparison(retail, settlement_net, liters, vat)
retail_gross = retail * liters
invoice_gross = costs['invoice_gross'] * liters
result = {
'liters': liters, 'retail_price': retail, 'retail_net_price': comparison['retail_net_price'],
'base_net_price': base_net, 'settlement_net': settlement_net, 'invoice_price': costs['invoice_gross'],
'effective_price': costs['effective_cost'], 'retail_gross': retail_gross,
'retail_net_total': comparison['retail_net_total'], 'settlement_net_total': comparison['settlement_net_total'],
'invoice_gross': invoice_gross, 'deductible_vat': costs['deductible_vat'] * liters,
'effective_cost': costs['effective_cost'] * liters,
'saving_net': comparison['saving_net_total'] if uses_last else D0,
'saving_net_per_liter': comparison['saving_net_per_liter'] if uses_last else D0,
'gross_saving': max(retail_gross - invoice_gross, D0),
'gross_saving_per_liter': max(retail - costs['invoice_gross'], D0),
'discount_percent': c['discount_percent'], 'surcharge_per_liter': c['surcharge_per_liter'],
'uses_last_price': uses_last, 'vat_rate': vat, 'vat_deduction_percent': deduction,
'rule_source': c.get('rule_source') or 'Migawka'}
return result
def freeze(entry, fallback_settings=None, conditions=None, source='Automatycznie przy zapisie', migration_note=None):
c = conditions or live_conditions(entry, fallback_settings)
result = calculate(entry, fallback_settings, c)
entry.settlement_frozen = True
entry.snapshot_vat_rate = c['vat_rate']; entry.snapshot_vat_deduction_percent = c['vat_deduction_percent']
entry.snapshot_uses_last_price = bool(c['uses_last_price']); entry.snapshot_discount_percent = c['discount_percent']
entry.snapshot_surcharge_per_liter = c['surcharge_per_liter']; entry.snapshot_rule_source = c.get('rule_source') or source
entry.snapshot_base_net_price = result['base_net_price']; entry.snapshot_settlement_net_price = result['settlement_net']
entry.result_retail_net_price = result['retail_net_price']; entry.result_invoice_price = result['invoice_price']
entry.result_effective_price = result['effective_price']; entry.result_retail_gross = result['retail_gross']
entry.result_retail_net_total = result['retail_net_total']; entry.result_settlement_net_total = result['settlement_net_total']
entry.result_invoice_gross = result['invoice_gross']; entry.result_deductible_vat = result['deductible_vat']
entry.result_effective_cost = result['effective_cost']; entry.result_saving_net = result['saving_net']
entry.result_gross_saving = result['gross_saving']; entry.settlement_snapshot_source = source
entry.settlement_migration_note = migration_note
return result
+2 -1
View File
@@ -1 +1,2 @@
.chart-wrap{height:420px;position:relative;overflow:hidden}.chart-box{height:320px;position:relative;overflow:hidden}.chart-empty{position:absolute;inset:0;display:grid;place-items:center;border:1px dashed var(--bs-border-color);border-radius:var(--bs-border-radius);color:var(--bs-secondary-color)} .chart-wrap{height:420px;position:relative;overflow:hidden}.chart-box{height:320px;position:relative;overflow:hidden}.chart-empty{position:absolute;inset:0;display:grid;place-items:center;border:1px dashed var(--bs-border-color);border-radius:.85rem;color:var(--bs-secondary-color);background:var(--bs-tertiary-bg)}
@media(max-width:575.98px){.chart-wrap{height:330px}.chart-box{height:270px}}
+7 -1
View File
@@ -1,2 +1,8 @@
.choices{margin-bottom:0}.choices__inner,.choices__input,.choices__list--dropdown,.choices__list[aria-expanded]{background-color:var(--bs-body-bg);color:var(--bs-body-color);border-color:var(--bs-border-color)}.choices__input{border-bottom-color:var(--bs-border-color)}.choices__list--dropdown,.choices__list[aria-expanded]{z-index:1080;max-height:260px;overflow:auto}.choices__list--dropdown .choices__item--selectable.is-highlighted,.choices__list[aria-expanded] .choices__item--selectable.is-highlighted{background-color:var(--bs-tertiary-bg)}.lpg-region-modal .modal-body{min-height:420px}.lpg-region-modal .choices__list--dropdown,.lpg-region-modal .choices__list[aria-expanded]{position:absolute;width:100%} .choices{margin-bottom:0}.choices__inner,.choices__input,.choices__list--dropdown,.choices__list[aria-expanded]{background-color:var(--bs-body-bg);color:var(--bs-body-color);border-color:var(--bs-border-color)}.choices__inner{border-radius:.7rem;min-height:2.75rem;padding:.45rem .75rem}.choices__input{border-bottom-color:var(--bs-border-color)}.choices__list--dropdown,.choices__list[aria-expanded]{z-index:1080;max-height:260px;overflow:auto}.choices__list--dropdown .choices__item--selectable.is-highlighted,.choices__list[aria-expanded] .choices__item--selectable.is-highlighted{background-color:var(--bs-tertiary-bg)}.lpg-region-modal .modal-body{min-height:420px}.lpg-region-modal .choices__list--dropdown,.lpg-region-modal .choices__list[aria-expanded]{position:absolute;width:100%}
[data-station-proposal-select]+.choices .choices__list--dropdown,[data-station-proposal-select]+.choices .choices__list[aria-expanded]{min-width:100%;width:max-content;max-width:min(42rem,90vw)} [data-station-proposal-select]+.choices .choices__list--dropdown,[data-station-proposal-select]+.choices .choices__list[aria-expanded]{min-width:100%;width:max-content;max-width:min(42rem,90vw)}
[data-bs-theme="dark"] .choices__inner,[data-bs-theme="dark"] .choices__input,[data-bs-theme="dark"] .choices__list--dropdown,[data-bs-theme="dark"] .choices__list[aria-expanded]{background:#171a1e!important;border-color:#4a5057!important;color:#f8f9fa!important}
[data-bs-theme="dark"] .choices__input::placeholder{color:#8f969e}
[data-bs-theme="dark"] .choices__list--single .choices__item,[data-bs-theme="dark"] .choices__item--choice{color:#f1f3f5}
[data-bs-theme="dark"] .choices__list--dropdown .choices__item--selectable.is-highlighted,[data-bs-theme="dark"] .choices__list[aria-expanded] .choices__item--selectable.is-highlighted{background:#2c3238!important;color:#fff}
[data-bs-theme="dark"] .choices[data-type*=select-one]::after{border-color:#aeb4bb transparent transparent}
[data-bs-theme="dark"] .choices[data-type*=select-one].is-open::after{border-color:transparent transparent #aeb4bb}
+27 -1
View File
@@ -1 +1,27 @@
.metric strong{display:block;font-size:1.45rem;margin-top:.25rem}.station-brand{font-weight:700}.station-company{font-size:.875rem;color:var(--bs-secondary-color)} .card{border:1px solid var(--bs-border-color-translucent);border-radius:var(--app-radius);box-shadow:var(--app-shadow);overflow:hidden}.card-body{padding:1.35rem}.card-title,.card h2{letter-spacing:-.02em}.card-header{background:transparent;border-bottom-color:var(--bs-border-color-translucent);padding:1rem 1.35rem}
.metric{height:100%;position:relative;transition:transform .18s ease,box-shadow .18s ease}.metric:hover{transform:translateY(-2px);box-shadow:var(--app-shadow-hover)}.metric small{color:var(--bs-secondary-color);font-weight:600}.metric strong{display:block;font-size:clamp(1.25rem,2vw,1.65rem);line-height:1.2;margin-top:.45rem;letter-spacing:-.035em}.metric::before{content:"";position:absolute;inset:0 auto 0 0;width:.25rem;background:var(--bs-primary)}
.btn{border-radius:.65rem;font-weight:600}.btn-primary{box-shadow:0 .2rem .65rem color-mix(in srgb,var(--bs-primary) 22%,transparent)}.form-control,.form-select{border-radius:.7rem;min-height:2.75rem}.form-control:focus,.form-select:focus{box-shadow:0 0 0 .22rem color-mix(in srgb,var(--bs-primary) 18%,transparent)}.form-label{font-size:.9rem;font-weight:650;margin-bottom:.4rem}
.table{--bs-table-bg:transparent;margin-bottom:0}.table>:not(caption)>*>*{padding:.8rem .75rem;border-bottom-color:var(--bs-border-color-translucent)}.table thead th{font-size:.75rem;text-transform:uppercase;letter-spacing:.045em;color:var(--bs-secondary-color);font-weight:700;white-space:nowrap}.table tbody tr:last-child td{border-bottom:0}.table-responsive{border-radius:.7rem}.badge{border-radius:50rem;font-weight:650;padding:.4em .65em}.nav-tabs{gap:.25rem;border-bottom-color:var(--bs-border-color)}.nav-tabs .nav-link{border-radius:.65rem .65rem 0 0;font-weight:600}.list-group-item{padding:.9rem 1rem}.modal-content{border:0;border-radius:var(--app-radius);box-shadow:0 1rem 3rem rgba(0,0,0,.18)}.modal-header,.modal-footer{border-color:var(--bs-border-color-translucent)}
.section-heading{display:flex;justify-content:space-between;align-items:center;gap:1rem;margin-bottom:1rem}.empty-state{padding:2.5rem 1rem;text-align:center;color:var(--bs-secondary-color)}.station-brand{font-weight:700}.station-company{font-size:.875rem;color:var(--bs-secondary-color)}
@media(max-width:575.98px){.card-body{padding:1.05rem}.metric .card-body{padding:1rem}.table>:not(caption)>*>*{padding:.7rem .6rem}.btn:not(.btn-sm):not(.btn-link){min-height:2.65rem}}
[data-bs-theme="dark"] .card,[data-bs-theme="dark"] .modal-content,[data-bs-theme="dark"] .list-group-item{background-color:#202327;border-color:#3a3f45;color:#f1f3f5}
[data-bs-theme="dark"] .card-header,[data-bs-theme="dark"] .modal-header,[data-bs-theme="dark"] .modal-footer{border-color:#3a3f45}
[data-bs-theme="dark"] .form-control,[data-bs-theme="dark"] .form-select{background-color:#171a1e;border-color:#4a5057;color:#f8f9fa}
[data-bs-theme="dark"] .form-control::placeholder{color:#8f969e;opacity:1}
[data-bs-theme="dark"] .form-control:focus,[data-bs-theme="dark"] .form-select:focus{background-color:#171a1e;border-color:var(--bs-primary);color:#fff}
[data-bs-theme="dark"] .form-select option{background:#171a1e;color:#f8f9fa}
[data-bs-theme="dark"] .input-group-text{background:#292d32;border-color:#4a5057;color:#d9dde1}
[data-bs-theme="dark"] .table{--bs-table-color:#e9ecef;--bs-table-hover-color:#fff;--bs-table-hover-bg:rgba(255,255,255,.045);--bs-table-striped-color:#e9ecef;--bs-table-striped-bg:rgba(255,255,255,.025)}
[data-bs-theme="dark"] .table thead th{color:#adb5bd}
[data-bs-theme="dark"] .text-body-secondary,[data-bs-theme="dark"] .text-muted{color:#aeb4bb!important}
[data-bs-theme="dark"] .bg-body-tertiary{background-color:#181b1f!important}
[data-bs-theme="dark"] .dropdown-menu{background:#202327;border-color:#3a3f45;box-shadow:0 .4rem 1.1rem rgba(0,0,0,.22)}
[data-bs-theme="dark"] .dropdown-item{color:#e9ecef}
[data-bs-theme="dark"] .dropdown-item:hover,[data-bs-theme="dark"] .dropdown-item:focus{background:#2b3035;color:#fff}
.vat-action-row>td{background-color:rgba(var(--bs-warning-rgb),.075)}
.vat-action-row:hover>td{background-color:rgba(var(--bs-warning-rgb),.13)!important}
.vat-action-badge{background:rgba(var(--bs-warning-rgb),.2);color:var(--bs-body-color);border:1px solid rgba(var(--bs-warning-rgb),.45);margin-top:.25rem}
[data-bs-theme="dark"] .vat-action-row>td{background-color:rgba(var(--bs-warning-rgb),.09)}
[data-bs-theme="dark"] .vat-action-row:hover>td{background-color:rgba(var(--bs-warning-rgb),.15)!important}
+31 -1
View File
@@ -1 +1,31 @@
html,body{min-height:100%}body{min-height:100vh}main{min-height:calc(100vh - 57px)}.navbar-brand{white-space:nowrap}#live-alerts{max-width:420px} :root{--app-radius:1rem;--app-shadow:0 .4rem 1.5rem rgba(15,23,42,.07);--app-shadow-hover:0 .75rem 2rem rgba(15,23,42,.11)}
html,body{min-height:100%}body{min-height:100vh;min-height:100dvh;display:flex;flex-direction:column;background:var(--bs-tertiary-bg);padding-left:env(safe-area-inset-left);padding-right:env(safe-area-inset-right)}
.app-navbar{background:color-mix(in srgb,var(--bs-body-bg) 92%,transparent);border-bottom:1px solid var(--bs-border-color-translucent);backdrop-filter:blur(14px);box-shadow:0 .15rem .75rem rgba(15,23,42,.04)}
.navbar-brand{font-weight:800;letter-spacing:-.04em;white-space:nowrap}.app-brand,.auth-brand{font-size:1.4rem;line-height:1;text-decoration:none}.brand-fuel{color:var(--bs-emphasis-color)}.brand-track{color:var(--bs-primary)}
.app-brand::after,.auth-brand::after{content:"";display:inline-block;width:.38rem;height:.38rem;margin-left:.18rem;border-radius:50%;background:var(--bs-warning);box-shadow:0 0 0 .2rem color-mix(in srgb,var(--bs-warning) 18%,transparent);vertical-align:.15em}
.app-navbar .nav-link{border-radius:.6rem;padding:.55rem .75rem;font-weight:550;color:var(--bs-secondary-color)}.app-navbar .nav-link:hover{color:var(--bs-emphasis-color);background:var(--bs-tertiary-bg)}.app-navbar .nav-link.active{color:var(--bs-primary);background:color-mix(in srgb,var(--bs-primary) 10%,transparent)}.navbar-divider{height:1.5rem;border-left:1px solid var(--bs-border-color);margin-left:.35rem}
.app-main{flex:1;padding-top:2rem;padding-bottom:3rem}.app-footer{padding:1.25rem 0;border-top:1px solid var(--bs-border-color-translucent);background:var(--bs-body-bg);color:var(--bs-secondary-color)}.app-footer a{color:inherit}
.page-header{display:flex;justify-content:space-between;align-items:flex-end;gap:1rem;margin-bottom:1.5rem}.page-header h1{letter-spacing:-.035em}.page-kicker{font-size:.78rem;text-transform:uppercase;letter-spacing:.08em;font-weight:700;color:var(--bs-primary);margin-bottom:.3rem}
#live-alerts,.app-alert-host{position:fixed;top:max(1rem,env(safe-area-inset-top));right:max(1rem,env(safe-area-inset-right));z-index:1080;width:min(420px,calc(100vw - 2rem));pointer-events:none}.app-alert-host .alert,#live-alerts .alert{pointer-events:auto}.app-alert{box-shadow:var(--app-shadow);border:0}
.auth-page{background:var(--bs-tertiary-bg)}.auth-main{display:grid;place-items:center;width:100%;padding-top:2rem;padding-bottom:2rem}.auth-shell{width:min(100%,28rem)}.auth-brand{display:block;width:max-content;margin:0 auto 1.25rem;font-size:1.8rem;font-weight:850;letter-spacing:-.055em}.auth-card{border-color:color-mix(in srgb,var(--bs-primary) 18%,var(--bs-border-color));box-shadow:none}
/* Geometria interfejsu jest wspólna dla wszystkich motywów. Motyw zmienia wyłącznie kolory. */
.app-navbar{min-height:4.25rem}
.app-navbar .container{min-height:4.25rem}
.app-navbar .navbar-brand{margin-right:1rem;padding-top:.3125rem;padding-bottom:.3125rem}
.app-navbar .navbar-toggler{padding:.25rem .75rem;font-size:1.25rem;line-height:1;border-width:1px;border-radius:.375rem}
.app-brand{font-family:inherit;font-size:1.4rem!important;font-weight:800!important;line-height:1!important;letter-spacing:-.04em!important}
.app-navbar .nav-link{font-size:1rem;line-height:1.5}
[data-bs-theme="dark"]{--app-shadow:0 .25rem .9rem rgba(0,0,0,.16);--app-shadow-hover:0 .45rem 1.25rem rgba(0,0,0,.22)}
[data-bs-theme="dark"] body{background:#17191c;color:#f1f3f5}
[data-bs-theme="dark"] .app-navbar{background:rgba(24,26,29,.94);border-bottom-color:#34383d;box-shadow:0 .1rem .45rem rgba(0,0,0,.18)}
[data-bs-theme="dark"] .app-footer{background:#1b1d20;border-top-color:#34383d}
[data-bs-theme="dark"] .brand-fuel{color:#f8f9fa}
[data-bs-theme="dark"] .auth-page{background:#141619}
@media(max-width:991.98px){.app-main{padding-top:1.35rem}.app-navbar .navbar-collapse{border-top:1px solid var(--bs-border-color-translucent);margin-top:.75rem}.app-navbar .nav-link{padding:.7rem .8rem}.page-header{align-items:stretch;flex-direction:column}}
@media(max-width:575.98px){.app-main{padding-left:1rem;padding-right:1rem}.page-header h1{font-size:1.65rem}.auth-main{padding-top:1.25rem;padding-bottom:1.25rem}}
@supports(padding:max(0px)){.app-navbar{padding-top:max(.5rem,env(safe-area-inset-top))}.app-footer{padding-bottom:max(1.25rem,env(safe-area-inset-bottom))}}
.auth-page .app-alert{box-shadow:none}
+1 -1
View File
@@ -1 +1 @@
(()=>{const F=FuelTrack;F.replaceMain=async(url=location.href,{push=false}={})=>{const r=await fetch(url,{headers:{'X-Requested-With':'XMLHttpRequest'}});if(!r.ok)throw new Error('Nie udało się odświeżyć widoku.');const doc=new DOMParser().parseFromString(await r.text(),'text/html');const incoming=F.qs('main',doc),current=F.qs('main');if(!incoming||!current)return;current.replaceWith(incoming);document.documentElement.dataset.bsTheme=doc.documentElement.dataset.bsTheme||'light';const next=F.qs('#bootstrap-theme',doc),theme=F.qs('#bootstrap-theme');if(next&&theme)theme.href=next.href;if(push)history.pushState({},'',url);F.initPage();F.qsa('script:not([src])',doc).forEach(x=>{if(x.textContent.includes('FuelTrack.'))try{Function(x.textContent)()}catch(e){console.error(e)}})};F.submitAjax=async form=>{const b=F.qs('[type=submit]',form),old=b?.innerHTML;if(b){b.disabled=true;b.innerHTML='Zapisywanie…'}try{const r=await fetch(form.action||location.href,{method:(form.dataset.method||form.getAttribute('method')||'POST').toUpperCase(),body:new FormData(form),headers:{'X-Requested-With':'XMLHttpRequest','Accept':'application/json'}});const d=await r.json();if(!r.ok||!d.ok)throw new Error(d.error||d.message||'Operacja nie powiodła się.');F.notify(d.message||'Zapisano');bootstrap.Modal.getInstance(form.closest('.modal'))?.hide();await F.replaceMain((d.data||{}).redirect||location.href,{push:Boolean((d.data||{}).redirect)})}catch(e){F.notify(e.message,'danger')}finally{if(b){b.disabled=false;b.innerHTML=old}}};F.bindAjaxForms=()=>{F.qsa('form.ajax-form').forEach(f=>{if(f.dataset.bound)return;f.dataset.bound='1';f.addEventListener('submit',e=>{e.preventDefault();F.submitAjax(f)})});F.qsa('form.ajax-nav-form').forEach(f=>{if(f.dataset.bound)return;f.dataset.bound='1';f.addEventListener('submit',e=>{e.preventDefault();F.replaceMain(`${f.action||location.pathname}?${new URLSearchParams(new FormData(f))}`,{push:true}).catch(x=>F.notify(x.message,'danger'))})});F.qsa('a.ajax-nav-link').forEach(a=>{if(a.dataset.bound)return;a.dataset.bound='1';a.addEventListener('click',e=>{e.preventDefault();F.replaceMain(a.href,{push:true}).catch(x=>F.notify(x.message,'danger'))})})}})(); (()=>{const F=FuelTrack;F.replaceMain=async(url=location.href,{push=false}={})=>{const r=await fetch(url,{headers:{'X-Requested-With':'XMLHttpRequest'}});if(!r.ok)throw new Error('Nie udało się odświeżyć widoku.');const doc=new DOMParser().parseFromString(await r.text(),'text/html');const incoming=F.qs('main',doc),current=F.qs('main');if(!incoming||!current)return;current.replaceWith(incoming);document.documentElement.dataset.bsTheme=doc.documentElement.dataset.bsTheme||'light';const next=F.qs('#bootstrap-theme',doc),theme=F.qs('#bootstrap-theme');if(next&&theme)theme.href=next.href;if(push)history.pushState({},'',url);F.initPage();F.qsa('script:not([src])',doc).forEach(x=>{if(x.textContent.includes('FuelTrack.'))try{Function(x.textContent)()}catch(e){console.error(e)}})};F.submitAjax=async form=>{const b=F.qs('[type=submit]',form),old=b?.innerHTML;if(b){b.disabled=true;b.innerHTML='Zapisywanie…'}try{const r=await fetch(form.action||location.href,{method:(form.dataset.method||form.getAttribute('method')||'POST').toUpperCase(),body:new FormData(form),headers:{'X-Requested-With':'XMLHttpRequest','Accept':'application/json'}});const d=await r.json();if(!r.ok||!d.ok)throw new Error(d.error||d.message||'Operacja nie powiodła się.');F.notify(d.message||'Zapisano');bootstrap.Modal.getInstance(form.closest('.modal'))?.hide();await F.replaceMain((d.data||{}).redirect||location.href,{push:Boolean((d.data||{}).redirect)})}catch(e){F.notify(e.message,'danger')}finally{if(b){b.disabled=false;b.innerHTML=old}}};F.bindAjaxForms=()=>{F.qsa('form.ajax-form').forEach(f=>{if(f.dataset.bound)return;f.dataset.bound='1';f.addEventListener('submit',e=>{e.preventDefault();F.submitAjax(f)})});F.qsa('form.ajax-nav-form').forEach(f=>{if(f.dataset.bound)return;f.dataset.bound='1';f.addEventListener('submit',e=>{e.preventDefault();(()=>{const url=new URL(f.getAttribute('action')||location.pathname,location.origin);url.search=new URLSearchParams(new FormData(f)).toString();return F.replaceMain(url.toString(),{push:true})})().catch(x=>F.notify(x.message,'danger'))})});F.qsa('a.ajax-nav-link').forEach(a=>{if(a.dataset.bound)return;a.dataset.bound='1';a.addEventListener('click',e=>{e.preventDefault();F.replaceMain(a.href,{push:true}).catch(x=>F.notify(x.message,'danger'))})})}})();
+1 -1
View File
@@ -1 +1 @@
(()=>{const F=FuelTrack;F.bindAuth=()=>{const login=F.qs('form.api-login-form');if(login&&!login.dataset.bound){login.dataset.bound='1';login.addEventListener('submit',async e=>{e.preventDefault();try{const r=await fetch('/api/auth/login',{method:'POST',body:new FormData(login),headers:{Accept:'application/json'}}),d=await r.json();if(!r.ok||!d.ok)throw new Error(d.message||'Błąd logowania');location.href=(d.data||{}).redirect||'/'}catch(x){F.notify(x.message,'danger')}})}F.qsa('[data-api-logout]').forEach(a=>{if(a.dataset.bound)return;a.dataset.bound='1';a.addEventListener('click',async e=>{e.preventDefault();const d=await (await fetch('/api/auth/logout',{method:'POST'})).json();location.href=(d.data||{}).redirect||'/login'})})}})(); (()=>{const F=FuelTrack;F.bindAuth=()=>{const login=F.qs('form.api-login-form');if(login&&!login.dataset.bound){login.dataset.bound='1';login.addEventListener('submit',async e=>{e.preventDefault();try{const r=await fetch('/api/auth/login',{method:'POST',body:new FormData(login),headers:{Accept:'application/json'}}),d=await r.json();if(!r.ok||!d.ok)throw new Error(d.message||'Błąd logowania');location.href=(d.data||{}).redirect||'/'}catch(x){F.notify(x.message,'danger')}})}}})();
+35 -1
View File
@@ -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:'Detal brutto (zł)',data:labels.map(k=>data[k].retail_gross)},
{label:'Do zapłaty brutto (zł)',data:labels.map(k=>data[k].invoice_gross)},
{label:'Różnica brutto (zł)',data:labels.map(k=>data[k].gross_saving)}
]},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 brutto (zł)',data:labels.map(k=>data[k].retail_gross)},
{label:'Do zapłaty brutto (zł)',data:labels.map(k=>data[k].invoice)},
{label:'Różnica brutto (zł)',data:labels.map(k=>data[k].gross_saving)}
]},options:{responsive:true,maintainAspectRatio:false,plugins:{legend:{labels:{color:text()}}},scales:commonScales()}});
};
})();
+1 -1
View File
@@ -2,5 +2,5 @@ window.FuelTrack=window.FuelTrack||{};
FuelTrack.charts={}; FuelTrack.charts={};
FuelTrack.qs=(s,r=document)=>r.querySelector(s); FuelTrack.qs=(s,r=document)=>r.querySelector(s);
FuelTrack.qsa=(s,r=document)=>[...r.querySelectorAll(s)]; FuelTrack.qsa=(s,r=document)=>[...r.querySelectorAll(s)];
FuelTrack.notify=(message,type='success')=>{let host=FuelTrack.qs('#live-alerts');if(!host){host=document.createElement('div');host.id='live-alerts';host.className='position-fixed top-0 end-0 p-3';host.style.zIndex='1080';document.body.append(host)}const el=document.createElement('div');el.className=`alert alert-${type} shadow`;el.textContent=message;host.append(el);setTimeout(()=>el.remove(),3500)}; FuelTrack.notify=(message,type='success')=>{let host=FuelTrack.qs('#live-alerts');if(!host){host=document.createElement('div');host.id='live-alerts';host.className='app-alert-host';document.body.append(host)}const el=document.createElement('div');el.className=`alert alert-${type} alert-dismissible fade show app-alert`;el.textContent=message;host.append(el);setTimeout(()=>el.remove(),3500)};
FuelTrack.debounce=(fn,delay=300)=>{let timer;return(...args)=>{clearTimeout(timer);timer=setTimeout(()=>fn(...args),delay)}}; FuelTrack.debounce=(fn,delay=300)=>{let timer;return(...args)=>{clearTimeout(timer);timer=setTimeout(()=>fn(...args),delay)}};
+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;const actionToggle=form.elements.vat_action_applied,actionFields=['vat_action_name','vat_action_rate','vat_action_start','vat_action_end'];const syncActionFields=()=>{const enabled=Boolean(actionToggle?.checked);actionFields.forEach(k=>{if(form.elements[k])form.elements[k].disabled=!enabled})};if(actionToggle&&!actionToggle.dataset.bound){actionToggle.dataset.bound='1';actionToggle.addEventListener('change',syncActionFields)}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','vat_action_name','vat_action_rate','vat_action_start','vat_action_end','snapshot_vat_rate','snapshot_vat_deduction_percent','snapshot_discount_percent','snapshot_surcharge_per_liter','snapshot_rule_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(form.elements.snapshot_uses_last_price)form.elements.snapshot_uses_last_price.checked=Boolean(d.snapshot_uses_last_price);const warning=document.getElementById('settlement-migration-warning');if(warning){warning.textContent=d.settlement_migration_note||'';warning.classList.toggle('d-none',!d.settlement_migration_note);}if(actionToggle)actionToggle.checked=Boolean(d.vat_action_applied);syncActionFields()})});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.initSocket();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());
+2 -1
View File
@@ -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.bindLpgRegionPicker=()=>{const select=F.qs('#lpg-region-select');if(!select||select.dataset.choicesBound)return;select.dataset.choicesBound='1';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 incrementalBtn=F.qs('#orlen-sync-btn'),fullBtn=F.qs('#orlen-full-sync-btn');if(!incrementalBtn||incrementalBtn.dataset.bound)return;incrementalBtn.dataset.bound='1';if(fullBtn)fullBtn.dataset.bound='1';const sync=async fullRefresh=>{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}if(fullRefresh&&!confirm(`Pobrać ponownie wszystkie dane dla ${year} roku? Operacja może potrwać dłużej.`))return;const regions=F.qsa('[name=region] option:checked',form).map(x=>x.value),old=incrementalBtn.innerHTML;incrementalBtn.disabled=true;if(fullBtn)fullBtn.disabled=true;incrementalBtn.innerHTML=fullRefresh?'Pełne pobieranie…':'Pobieranie…';try{const r=await fetch('/api/orlen/sync',{method:'POST',headers:{'Content-Type':'application/json','X-Requested-With':'XMLHttpRequest'},body:JSON.stringify({fuels,year,regions,full_refresh:fullRefresh})}),d=await r.json();if(!r.ok||!d.ok)throw new Error(d.error||d.message||'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{incrementalBtn.disabled=false;if(fullBtn)fullBtn.disabled=false;incrementalBtn.innerHTML=old}};incrementalBtn.addEventListener('click',()=>sync(false));fullBtn?.addEventListener('click',()=>sync(true))}})();
;(()=>{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()}}})();
+2
View File
@@ -2,4 +2,6 @@
<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>
<li class="nav-item"><a class="nav-link {% if request.endpoint=='main.admin_reports' %}active{% endif %}" href="{{url_for('main.admin_reports')}}">Zestawienia kosztów</a></li>
</ul> </ul>
+1 -1
View File
@@ -9,7 +9,7 @@
<div class="col-12"><label class="form-label">Województwo dla LPG</label><select class="form-select" name="region" required>{% for region in regions %}<option value="{{region}}" {% if settings.region==region %}selected{% endif %}>{{region}}</option>{% endfor %}</select></div><div class="col-12"><button class="btn btn-primary">Zapisz ustawienia</button></div></form></div></div></div> <div class="col-12"><label class="form-label">Województwo dla LPG</label><select class="form-select" name="region" required>{% for region in regions %}<option value="{{region}}" {% if settings.region==region %}selected{% endif %}>{{region}}</option>{% endfor %}</select></div><div class="col-12"><button class="btn btn-primary">Zapisz ustawienia</button></div></form></div></div></div>
<div class="col-xl-6"><div class="card"><div class="card-body"><h2 class="h5">Dodaj użytkownika</h2><form method="post" action="/api/users" class="row g-3 ajax-form"><div class="col-md-6"><label class="form-label">Imię i nazwisko</label><input class="form-control" name="user_name" required></div><div class="col-md-6"><label class="form-label">E-mail</label><input class="form-control" type="email" name="email" required></div><div class="col-md-6"><label class="form-label">Hasło</label><input class="form-control" type="password" name="password" minlength="8" required></div><div class="col-md-6"><label class="form-label">Rola</label><select class="form-select" name="role"><option value="user">User</option><option value="boss">Szef</option><option value="admin">Admin</option></select></div><div class="col-12"><button class="btn btn-primary">Dodaj użytkownika</button></div></form></div></div></div></div> <div class="col-xl-6"><div class="card"><div class="card-body"><h2 class="h5">Dodaj użytkownika</h2><form method="post" action="/api/users" class="row g-3 ajax-form"><div class="col-md-6"><label class="form-label">Imię i nazwisko</label><input class="form-control" name="user_name" required></div><div class="col-md-6"><label class="form-label">E-mail</label><input class="form-control" type="email" name="email" required></div><div class="col-md-6"><label class="form-label">Hasło</label><input class="form-control" type="password" name="password" minlength="8" required></div><div class="col-md-6"><label class="form-label">Rola</label><select class="form-select" name="role"><option value="user">User</option><option value="boss">Szef</option><option value="admin">Admin</option></select></div><div class="col-12"><button class="btn btn-primary">Dodaj użytkownika</button></div></form></div></div></div></div>
<div class="row g-4 mt-1"> <div class="row g-4 mt-1">
<div class="col-xl-5"><div class="card"><div class="card-body"><h2 class="h5">Ustawienia aplikacji</h2><form action="/api/app-settings" data-method="PUT" class="ajax-form row g-3"><div class="col-12"><label class="form-label">Motyw aplikacji</label><select class="form-select" name="theme">{% for key,item in themes.items() %}<option value="{{key}}" {% if app_theme_name==key %}selected{% endif %}>{{item.label}}</option>{% endfor %}</select></div><div class="col-12"><button class="btn btn-primary">Zapisz ustawienia aplikacji</button></div></form></div></div></div> <div class="col-xl-5"><div class="card"><div class="card-body"><h2 class="h5">Ustawienia aplikacji</h2><form action="/api/app-settings" data-method="PUT" class="ajax-form row g-3"><div class="col-12"><label class="form-label">Motyw aplikacji</label><select class="form-select" name="theme">{% for key,item in themes.items() %}<option value="{{key}}" {% if app_theme_name==key %}selected{% endif %}>{{item.label}}</option>{% endfor %}</select><div class="form-text">Wybrany motyw jest ustawiany globalnie dla wszystkich użytkowników aplikacji.</div></div><div class="col-12"><button class="btn btn-primary">Zapisz ustawienia aplikacji</button></div></form></div></div></div>
<div class="col-xl-7"><div class="card"><div class="card-body"><h2 class="h5">Karty paliwowe firmy</h2><form action="/api/fuel-cards" class="ajax-form row g-2 mb-3"><div class="col-md-4"><input class="form-control" name="name" placeholder="Nazwa, np. UTA Polska" required></div><div class="col-md-3"><input class="form-control" name="provider" placeholder="Operator" required></div><div class="col-md-3"><input class="form-control" name="description" placeholder="Opis"></div><div class="col-md-2"><button class="btn btn-primary w-100">Dodaj</button></div></form><div class="list-group">{% for card in fuel_cards %}<div class="list-group-item d-flex justify-content-between align-items-center"><div><strong>{{card.name}}</strong><div class="small text-body-secondary">{{card.provider}}{% if card.description %} · {{card.description}}{% endif %}</div></div><span class="badge text-bg-{{'success' if card.active else 'secondary'}}">{{'aktywna' if card.active else 'nieaktywna'}}</span></div>{% else %}<div class="text-body-secondary">Brak kart paliwowych.</div>{% endfor %}</div></div></div></div> <div class="col-xl-7"><div class="card"><div class="card-body"><h2 class="h5">Karty paliwowe firmy</h2><form action="/api/fuel-cards" class="ajax-form row g-2 mb-3"><div class="col-md-4"><input class="form-control" name="name" placeholder="Nazwa, np. UTA Polska" required></div><div class="col-md-3"><input class="form-control" name="provider" placeholder="Operator" required></div><div class="col-md-3"><input class="form-control" name="description" placeholder="Opis"></div><div class="col-md-2"><button class="btn btn-primary w-100">Dodaj</button></div></form><div class="list-group">{% for card in fuel_cards %}<div class="list-group-item d-flex justify-content-between align-items-center"><div><strong>{{card.name}}</strong><div class="small text-body-secondary">{{card.provider}}{% if card.description %} · {{card.description}}{% endif %}</div></div><span class="badge text-bg-{{'success' if card.active else 'secondary'}}">{{'aktywna' if card.active else 'nieaktywna'}}</span></div>{% else %}<div class="text-body-secondary">Brak kart paliwowych.</div>{% endfor %}</div></div></div></div>
</div> </div>
{% if current_user.role=='admin' %}<div class="card mt-4"><div class="card-body"><div class="d-flex flex-column flex-md-row justify-content-between gap-3 mb-3"><div><h2 class="h5 mb-1">Użytkownicy</h2><small class="text-body-secondary">{{ users_page.total }} kont</small></div><form method="get" action="{{url_for('main.admin')}}" class="d-flex gap-2 ajax-nav-form"><input class="form-control" name="q" value="{{q}}" placeholder="Szukaj po nazwie lub e-mailu"><button class="btn btn-secondary">Szukaj</button></form></div><div class="table-responsive"><table class="table table-hover align-middle"><thead><tr><th>Nazwa</th><th>E-mail</th><th>Rola</th><th>Status</th><th class="text-end">Akcja</th></tr></thead><tbody>{% for u in users_page.items %}<tr><td>{{u.name}}</td><td>{{u.email}}</td><td><span class="badge text-bg-secondary">{{u.role}}</span></td><td>{% if u.active %}<span class="badge text-bg-success">Aktywny</span>{% else %}<span class="badge text-bg-danger">Nieaktywny</span>{% endif %}</td><td class="text-end"><button type="button" class="btn btn-sm btn-primary user-edit-btn" data-bs-toggle="modal" data-bs-target="#userEditModal" data-id="{{u.id}}" data-name="{{u.name|e}}" data-email="{{u.email|e}}" data-role="{{u.role}}" data-active="{{1 if u.active else 0}}" data-card="{{u.fuel_card_id or ''}}">Edytuj</button></td></tr>{% else %}<tr><td colspan="5" class="text-center text-body-secondary py-4">Brak użytkowników.</td></tr>{% endfor %}</tbody></table></div>{% if users_page.pages > 1 %}<nav><ul class="pagination mb-0">{% for p in users_page.iter_pages() %}{% if p %}<li class="page-item {% if p==users_page.page %}active{% endif %}"><a class="page-link ajax-nav-link" href="{{url_for('main.admin',page=p,q=q)}}">{{p}}</a></li>{% else %}<li class="page-item disabled"><span class="page-link"></span></li>{% endif %}{% endfor %}</ul></nav>{% endif %}</div></div> {% if current_user.role=='admin' %}<div class="card mt-4"><div class="card-body"><div class="d-flex flex-column flex-md-row justify-content-between gap-3 mb-3"><div><h2 class="h5 mb-1">Użytkownicy</h2><small class="text-body-secondary">{{ users_page.total }} kont</small></div><form method="get" action="{{url_for('main.admin')}}" class="d-flex gap-2 ajax-nav-form"><input class="form-control" name="q" value="{{q}}" placeholder="Szukaj po nazwie lub e-mailu"><button class="btn btn-secondary">Szukaj</button></form></div><div class="table-responsive"><table class="table table-hover align-middle"><thead><tr><th>Nazwa</th><th>E-mail</th><th>Rola</th><th>Status</th><th class="text-end">Akcja</th></tr></thead><tbody>{% for u in users_page.items %}<tr><td>{{u.name}}</td><td>{{u.email}}</td><td><span class="badge text-bg-secondary">{{u.role}}</span></td><td>{% if u.active %}<span class="badge text-bg-success">Aktywny</span>{% else %}<span class="badge text-bg-danger">Nieaktywny</span>{% endif %}</td><td class="text-end"><button type="button" class="btn btn-sm btn-primary user-edit-btn" data-bs-toggle="modal" data-bs-target="#userEditModal" data-id="{{u.id}}" data-name="{{u.name|e}}" data-email="{{u.email|e}}" data-role="{{u.role}}" data-active="{{1 if u.active else 0}}" data-card="{{u.fuel_card_id or ''}}">Edytuj</button></td></tr>{% else %}<tr><td colspan="5" class="text-center text-body-secondary py-4">Brak użytkowników.</td></tr>{% endfor %}</tbody></table></div>{% if users_page.pages > 1 %}<nav><ul class="pagination mb-0">{% for p in users_page.iter_pages() %}{% if p %}<li class="page-item {% if p==users_page.page %}active{% endif %}"><a class="page-link ajax-nav-link" href="{{url_for('main.admin',page=p,q=q)}}">{{p}}</a></li>{% else %}<li class="page-item disabled"><span class="page-link"></span></li>{% endif %}{% endfor %}</ul></nav>{% endif %}</div></div>
+1 -1
View File
@@ -1,4 +1,4 @@
{% extends 'base.html' %}{% block title %}Ustawienia aplikacji{% endblock %}{% block content %} {% extends 'base.html' %}{% block title %}Ustawienia aplikacji{% endblock %}{% block content %}
<h1 class="h2 mb-3">Administracja</h1>{% include '_admin_nav.html' %} <h1 class="h2 mb-3">Administracja</h1>{% include '_admin_nav.html' %}
<div class="card"><div class="card-body"><h2 class="h5">Wygląd aplikacji</h2><form action="/api/app-settings" data-method="PUT" class="ajax-form row g-3"><div class="col-md-6"><label class="form-label">Motyw Bootstrap</label><select class="form-select" name="theme">{% for key,t in themes.items() %}<option value="{{key}}" {% if key==app_theme_name %}selected{% endif %}>{{t.label}}</option>{% endfor %}</select></div><div class="col-12"><button class="btn btn-primary">Zapisz ustawienia aplikacji</button></div></form></div></div> <div class="card"><div class="card-body"><h2 class="h5">Wygląd aplikacji</h2><form action="/api/app-settings" data-method="PUT" class="ajax-form row g-3"><div class="col-md-6"><label class="form-label">Motyw Bootstrap</label><select class="form-select" name="theme">{% for key,t in themes.items() %}<option value="{{key}}" {% if key==app_theme_name %}selected{% endif %}>{{t.label}}</option>{% endfor %}</select><div class="form-text">Wybrany motyw jest ustawiany globalnie dla wszystkich użytkowników aplikacji.</div></div><div class="col-12"><button class="btn btn-primary">Zapisz ustawienia aplikacji</button></div></form></div></div>
{% endblock %} {% endblock %}
+40
View File
@@ -0,0 +1,40 @@
{% 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 "","vat_action_applied":e.vat_action_applied,"vat_action_name":e.vat_action_name or "","vat_action_rate":e.vat_action_rate|string if e.vat_action_rate is not none else "","vat_action_start":e.vat_action_start.isoformat() if e.vat_action_start else "","vat_action_end":e.vat_action_end.isoformat() if e.vat_action_end else "","snapshot_vat_rate":e.snapshot_vat_rate|string if e.snapshot_vat_rate is not none else "","snapshot_vat_deduction_percent":e.snapshot_vat_deduction_percent|string if e.snapshot_vat_deduction_percent is not none else "","snapshot_uses_last_price":e.snapshot_uses_last_price,"snapshot_discount_percent":e.snapshot_discount_percent|string if e.snapshot_discount_percent is not none else "0","snapshot_surcharge_per_liter":e.snapshot_surcharge_per_liter|string if e.snapshot_surcharge_per_liter is not none else "0","snapshot_rule_source":e.snapshot_rule_source or "","settlement_migration_note":e.settlement_migration_note 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"><form id="fuel-entry-edit-form" class="modal-content 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 class="col-12"><hr class="my-1"><div class="form-check"><input class="form-check-input" type="checkbox" name="vat_action_applied" id="edit-vat-action-applied"><label class="form-check-label fw-semibold" for="edit-vat-action-applied">Tankowanie objęte akcją</label></div><div class="form-text">Warunki są zapisywane wyłącznie dla tego tankowania i nie zmieniają ustawień globalnych.</div></div>
<div class="col-md-6 vat-action-field"><label class="form-label">Nazwa akcji</label><input class="form-control" name="vat_action_name"></div>
<div class="col-md-2 vat-action-field"><label class="form-label">Stawka VAT (%)</label><input class="form-control" type="number" step="0.001" min="0" max="100" name="vat_action_rate"></div>
<div class="col-md-2 vat-action-field"><label class="form-label">Od</label><input class="form-control" type="date" name="vat_action_start"></div>
<div class="col-md-2 vat-action-field"><label class="form-label">Do</label><input class="form-control" type="date" name="vat_action_end"></div>
<div class="col-12"><hr class="my-1"><h3 class="h6 mb-1">Warunki rozliczenia tego tankowania</h3><div class="form-text">Zmiana warunków automatycznie przeliczy i zapisze wszystkie kwoty historyczne. Nie zmienia ustawień firmy ani karty.</div><div class="alert alert-warning py-2 mt-2 mb-0 d-none" id="settlement-migration-warning"></div></div>
<div class="col-md-3"><label class="form-label">VAT (%)</label><input class="form-control" type="number" step="0.001" min="0" max="100" name="snapshot_vat_rate" required></div>
<div class="col-md-3"><label class="form-label">Odliczenie VAT (%)</label><input class="form-control" type="number" step="0.001" min="0" max="100" name="snapshot_vat_deduction_percent" required></div>
<div class="col-md-3"><label class="form-label">Rabat netto (%)</label><input class="form-control" type="number" step="0.001" min="0" max="100" name="snapshot_discount_percent" required></div>
<div class="col-md-3"><label class="form-label">Dopłata netto / l</label><input class="form-control" type="number" step="0.0001" name="snapshot_surcharge_per_liter" required></div>
<div class="col-md-6"><div class="form-check mt-4"><input class="form-check-input" type="checkbox" name="snapshot_uses_last_price" id="edit-snapshot-last-price"><label class="form-check-label" for="edit-snapshot-last-price">Użyj zapisanej ceny hurtowej jako Last Price</label></div></div>
<div class="col-md-6"><label class="form-label">Źródło / opis reguły</label><input class="form-control" name="snapshot_rule_source" maxlength="160"></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>
{% endblock %}
+21
View File
@@ -0,0 +1,21 @@
{% extends 'base.html' %}{% block content %}
<div class="mb-4"><h1 class="h2 mb-1">Zestawienia kosztów</h1><p class="text-body-secondary mb-0">Porównanie detalu brutto z ostatecznymi kwotami faktur, rabatami, Last Price, VAT i kosztem po odliczeniu.</p></div>
{% include '_admin_nav.html' %}
<div class="card mb-4"><div class="card-body"><form method="get" class="row g-3 ajax-nav-form" action="{{url_for('main.admin_reports')}}">
{% 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-2"><label class="form-label">Zakres</label><select class="form-select" name="period" id="report-period"><option value="month" {% if period=='month' %}selected{% endif %}>Miesiąc</option><option value="year" {% if period=='year' %}selected{% endif %}>Rok</option><option value="custom" {% if period=='custom' %}selected{% endif %}>Zakres dat</option></select></div>
<div class="col-lg-2 report-filter report-month"><label class="form-label">Miesiąc</label><input class="form-control" type="month" name="month" value="{{month}}"></div><div class="col-lg-2 report-filter report-year"><label class="form-label">Rok</label><input class="form-control" type="number" min="2000" max="2100" name="year" value="{{year}}"></div><div class="col-lg-2 report-filter report-custom"><label class="form-label">Od</label><input class="form-control" type="date" name="date_from" value="{{date_from}}"></div><div class="col-lg-2 report-filter report-custom"><label class="form-label">Do</label><input class="form-control" type="date" name="date_to" value="{{date_to}}"></div><div class="col-lg-2 d-flex align-items-end"><button class="btn btn-primary w-100">Pokaż zestawienie</button></div></form></div></div>
<p class="small text-body-secondary">Okres: <strong>{{start_date.strftime('%d.%m.%Y')}}{{end_date.strftime('%d.%m.%Y')}}</strong></p>
<div class="row g-3 mb-4">
<div class="col-6 col-xl-2"><div class="card metric h-100"><div class="card-body"><small>Detal brutto</small><strong>{{'%.2f'|format(totals.retail_gross)}} zł</strong></div></div></div>
<div class="col-6 col-xl-2"><div class="card metric h-100"><div class="card-body"><small>Do zapłaty brutto</small><strong>{{'%.2f'|format(totals.invoice)}} zł</strong><span>po rabatach i dopłatach</span></div></div></div>
<div class="col-6 col-xl-2"><div class="card metric h-100"><div class="card-body"><small>Różnica brutto do detalu</small><strong>{{'%.2f'|format(totals.gross_saving)}} zł</strong><span>faktura vs detal</span></div></div></div>
<div class="col-6 col-xl-2"><div class="card metric h-100"><div class="card-body"><small>Oszczędność Last Price netto</small><strong>{{'%.2f'|format(totals.saving_net)}} zł</strong><span>{{'%.2f'|format(totals.saving_percent)}}%</span></div></div></div>
<div class="col-6 col-xl-2"><div class="card metric h-100"><div class="card-body"><small>Koszt po VAT</small><strong>{{'%.2f'|format(totals.effective)}} zł</strong><span>odliczono {{'%.2f'|format(totals.vat_deducted)}} zł VAT</span></div></div></div>
<div class="col-6 col-xl-2"><div class="card metric h-100"><div class="card-body"><small>Tankowania / litry</small><strong>{{totals.count}} / {{'%.2f'|format(totals.liters)}} l</strong><span>Last Price: {{totals.last_price_count}}</span></div></div></div>
</div>
<div class="alert alert-info small"><strong>Metoda:</strong> wykres porównuje detal brutto z ostateczną fakturą brutto po zastosowaniu reguły danej karty i stacji. Reguła stacji ma pierwszeństwo; gdy jej brak, używana jest polityka pojazdu. Last Price i oszczędność handlowa pozostają liczone netto, a VAT jest prezentowany osobno.</div>
<div class="card mb-4"><div class="card-body"><h2 class="h5">Kwoty brutto w czasie</h2><p class="small text-body-secondary">Detal brutto, ostateczna kwota do zapłaty brutto i różnica dla każdego miesiąca.</p><div class="chart-box"><canvas id="report-cost-chart"></canvas><div id="report-cost-chart-empty" class="chart-empty d-none">Brak danych dla wybranego okresu.</div></div></div></div>
<div class="row g-4"><div class="col-xl-6"><div class="card h-100"><div class="card-body"><h2 class="h5">Według pojazdu</h2><div class="table-responsive"><table class="table table-hover align-middle"><thead><tr><th>Pojazd</th><th>Tank.</th><th>Litry</th><th>Detal brutto</th><th>Do zapłaty brutto</th><th>Różnica brutto</th><th>Oszczędność LP netto</th><th>Koszt po VAT</th></tr></thead><tbody>{% for name,row in by_vehicle.items() %}<tr><td><strong>{{name}}</strong></td><td>{{row.count}}</td><td>{{'%.2f'|format(row.liters)}}</td><td>{{'%.2f'|format(row.retail_gross)}} zł</td><td>{{'%.2f'|format(row.invoice)}} zł</td><td><strong>{{'%.2f'|format(row.gross_saving)}} zł</strong></td><td>{{'%.2f'|format(row.saving_net)}} zł</td><td>{{'%.2f'|format(row.effective)}} zł</td></tr>{% else %}<tr><td colspan="8">Brak danych.</td></tr>{% endfor %}</tbody></table></div></div></div></div>
<div class="col-xl-6"><div class="card h-100"><div class="card-body"><h2 class="h5">Według firmy</h2><div class="table-responsive"><table class="table table-hover align-middle"><thead><tr><th>Firma</th><th>Tank.</th><th>Litry</th><th>Detal brutto</th><th>Do zapłaty brutto</th><th>Różnica brutto</th><th>Oszczędność LP netto</th><th>Koszt po VAT</th></tr></thead><tbody>{% for name,row in by_company.items() %}<tr><td><strong>{{name}}</strong></td><td>{{row.count}}</td><td>{{'%.2f'|format(row.liters)}}</td><td>{{'%.2f'|format(row.retail_gross)}} zł</td><td>{{'%.2f'|format(row.invoice)}} zł</td><td><strong>{{'%.2f'|format(row.gross_saving)}} zł</strong></td><td>{{'%.2f'|format(row.saving_net)}} zł</td><td>{{'%.2f'|format(row.effective)}} zł</td></tr>{% else %}<tr><td colspan="8">Brak danych.</td></tr>{% endfor %}</tbody></table></div></div></div></div></div>
{% endblock %}{% block scripts %}<script>FuelTrack.renderCostReportChart('report-cost-chart', {{by_month|tojson}});(()=>{const select=document.getElementById('report-period');const refresh=()=>{document.querySelectorAll('.report-filter').forEach(el=>el.classList.add('d-none'));document.querySelectorAll('.report-'+select.value).forEach(el=>el.classList.remove('d-none'));};select?.addEventListener('change',refresh);refresh();})();</script>{% endblock %}
+43 -7
View File
@@ -2,7 +2,10 @@
<html lang="pl" data-bs-theme="{{ app_theme.mode }}"> <html lang="pl" data-bs-theme="{{ app_theme.mode }}">
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1"> <meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<meta name="theme-color" content="#0d6efd">
<title>{% block title %}FuelTrack{% endblock %}</title> <title>{% block title %}FuelTrack{% endblock %}</title>
<link id="bootstrap-theme" rel="stylesheet" href="{{ app_theme.url }}"> <link id="bootstrap-theme" rel="stylesheet" href="{{ app_theme.url }}">
<link rel="stylesheet" href="{{ asset_urls.choices_css }}"> <link rel="stylesheet" href="{{ asset_urls.choices_css }}">
@@ -11,14 +14,47 @@
<link rel="stylesheet" href="{{ static_url('css/charts.css') }}"> <link rel="stylesheet" href="{{ static_url('css/charts.css') }}">
<link rel="stylesheet" href="{{ static_url('css/choices.css') }}"> <link rel="stylesheet" href="{{ static_url('css/choices.css') }}">
</head> </head>
<body> <body class="{% if not current_user.is_authenticated %}auth-page{% endif %}">
<nav class="navbar navbar-expand-lg bg-body-tertiary border-bottom sticky-top"><div class="container"><a class="navbar-brand fw-bold text-body-emphasis" href="{{ url_for('main.dashboard') }}">FuelTrack</a>{% if current_user.is_authenticated %}<div class="d-flex gap-2 align-items-center flex-wrap"><a class="btn btn-sm btn-secondary" href="{{ url_for('main.dashboard') }}">Podsumowanie</a><a class="btn btn-sm btn-secondary" href="{{ url_for('main.fuel') }}">Tankowanie</a><a class="btn btn-sm btn-secondary" href="{{ url_for('main.vehicles') }}">Pojazdy</a><a class="btn btn-sm btn-secondary" href="{{ url_for('main.orlen_data') }}">Ceny Orlen</a>{% if current_user.role in ['boss','admin'] %}<a class="btn btn-sm btn-secondary" href="{{ url_for('main.stations') }}">Stacje</a><a class="btn btn-sm btn-secondary" href="{{ url_for('main.admin_companies') }}">Administracja</a>{% endif %}<a class="btn btn-sm btn-danger" href="/api/auth/logout" data-api-logout>Wyloguj</a></div>{% endif %}</div></nav> {% if current_user.is_authenticated %}
<main class="container py-4">{% with msgs=get_flashed_messages(with_categories=true) %}{% for cat,msg in msgs %}<div class="alert alert-{{cat}}">{{msg}}</div>{% endfor %}{% endwith %}{% block content %}{% endblock %}</main> <nav class="navbar navbar-expand-lg app-navbar sticky-top" aria-label="Główna nawigacja">
<footer class="border-top mt-auto py-3"><div class="container d-flex justify-content-between align-items-center small text-body-secondary"><span>FuelTrack</span><a href="/docs" target="_blank" rel="noopener">API Docs</a></div></footer> <div class="container">
<a class="navbar-brand app-brand" href="{{ url_for('main.dashboard') }}" aria-label="FuelTrack — strona główna">
<span class="brand-fuel">Fuel</span><span class="brand-track">Track</span>
</a>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#mainNavigation" aria-controls="mainNavigation" aria-expanded="false" aria-label="Pokaż nawigację"><span class="navbar-toggler-icon"></span></button>
<div class="collapse navbar-collapse" id="mainNavigation">
<div class="navbar-nav ms-auto align-items-lg-center gap-lg-1 py-3 py-lg-0">
<a class="nav-link {% if request.endpoint=='main.dashboard' %}active{% endif %}" href="{{ url_for('main.dashboard') }}">Podsumowanie</a>
<a class="nav-link {% if request.endpoint=='main.fuel' %}active{% endif %}" href="{{ url_for('main.fuel') }}">Tankowanie</a>
<a class="nav-link {% if request.endpoint=='main.vehicles' %}active{% endif %}" href="{{ url_for('main.vehicles') }}">Pojazdy</a>
<a class="nav-link {% if request.endpoint=='main.orlen_data' %}active{% endif %}" href="{{ url_for('main.orlen_data') }}">Ceny Orlen</a>
{% if current_user.role in ['boss','admin'] %}
<a class="nav-link {% if request.endpoint=='main.stations' %}active{% endif %}" href="{{ url_for('main.stations') }}">Stacje</a>
<a class="nav-link {% if request.endpoint and request.endpoint.startswith('main.admin') %}active{% endif %}" href="{{ url_for('main.admin_companies') }}">Administracja</a>
{% endif %}
<span class="navbar-divider d-none d-lg-block"></span>
<form method="post" action="{{ url_for('auth.logout') }}" class="d-inline ms-lg-2">
<button class="btn btn-outline-danger btn-sm" type="submit">Wyloguj</button>
</form>
</div>
</div>
</div>
</nav>
{% endif %}
{% with msgs=get_flashed_messages(with_categories=true) %}
{% if msgs %}
<div id="server-alerts" class="app-alert-host" aria-live="polite" aria-atomic="true">
{% for cat,msg in msgs %}<div class="alert alert-{{cat}} alert-dismissible fade show app-alert" role="alert">{{msg}}<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Zamknij"></button></div>{% endfor %}
</div>
{% endif %}
{% endwith %}
<main class="container app-main {% if not current_user.is_authenticated %}auth-main{% endif %}">
{% block content %}{% endblock %}
</main>
{% if current_user.is_authenticated %}<footer class="app-footer"><div class="container d-flex justify-content-between align-items-center small"><span>@linuxiarz.pl</span><a href="/docs" target="_blank" rel="noopener">Dokumentacja API</a></div></footer>{% endif %}
<script src="{{ asset_urls.bootstrap_js }}"></script> <script src="{{ asset_urls.bootstrap_js }}"></script>
<script src="{{ asset_urls.choices_js }}"></script> <script src="{{ asset_urls.choices_js }}"></script>
<script src="{{ asset_urls.chart_js }}"></script> <script src="{{ asset_urls.chart_js }}"></script>
<script src="{{ asset_urls.socket_io_js }}"></script>
<script src="{{ static_url('js/core.js') }}"></script> <script src="{{ static_url('js/core.js') }}"></script>
<script src="{{ static_url('js/charts.js') }}"></script> <script src="{{ static_url('js/charts.js') }}"></script>
<script src="{{ static_url('js/ajax.js') }}"></script> <script src="{{ static_url('js/ajax.js') }}"></script>
@@ -27,7 +63,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/socket.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>
+12 -5
View File
@@ -1,7 +1,14 @@
{% extends 'base.html' %}{% block content %} {% extends 'base.html' %}{% block content %}
<div class="d-flex justify-content-between align-items-center mb-3"><div><h1 class="h2 mb-0">Podsumowanie kosztów</h1><small class="text-body-secondary">{{ settings.name }} · VAT do odliczenia {{ settings.vat_deduction_percent }}%</small></div><form action="{{url_for('main.dashboard')}}" class="ajax-nav-form"><input class="form-control" type="month" name="month" value="{{month}}"></form></div> <div class="d-flex flex-wrap justify-content-between align-items-end gap-3 mb-4"><div><h1 class="h2 mb-1">Dashboard</h1><p class="text-body-secondary mb-0">Porównanie cen netto, faktur i kosztów po VAT.</p></div><form method="get" class="d-flex gap-2"><input class="form-control" type="month" name="month" value="{{month}}"><button class="btn btn-primary">Pokaż</button></form></div>
{% if vat_override.enabled %}<div class="alert alert-warning"><strong>{{ vat_override.name }}:</strong> 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.</div>{% endif %} <div class="row g-3 mb-4">
<div class="row g-3 mb-4"><div class="col-6 col-lg-3"><div class="card metric"><div class="card-body"><small>Brutto</small><strong>{{ '%.2f'|format(totals.gross) }} zł</strong></div></div></div><div class="col-6 col-lg-3"><div class="card metric"><div class="card-body"><small>VAT odliczony</small><strong>{{ '%.2f'|format(totals.deductible_vat) }} zł</strong></div></div></div><div class="col-6 col-lg-3"><div class="card metric"><div class="card-body"><small>Koszt po VAT</small><strong>{{ '%.2f'|format(totals.final_cost) }} zł</strong></div></div></div><div class="col-6 col-lg-3"><div class="card metric"><div class="card-body"><small>Litry</small><strong>{{ '%.2f'|format(totals.liters) }} l</strong></div></div></div></div> <div class="col-6 col-xl-2"><div class="card metric h-100"><div class="card-body"><small>Detal netto</small><strong>{{ '%.2f'|format(totals.retail_net) }} zł</strong><span>cena z dystrybutora bez VAT</span></div></div></div>
<div class="row g-4"><div class="col-lg-7"><div class="card"><div class="card-body"><h2 class="h5">Koszt według pojazdu</h2><p class="small text-body-secondary">Cena na dystrybutorze a kwota do zapłaty według cennika karty.</p><div class="chart-box"><canvas id="cost-chart"></canvas><div id="cost-chart-empty" class="chart-empty d-none">Brak danych dla wybranego miesiąca.</div></div></div></div></div><div class="col-lg-5"><div class="card h-100"><div class="card-body"><h2 class="h5">Okresy faktur</h2><small class="text-body-secondary">{% if split_enabled %}Podział według dnia {{settings.invoice_split_day}}{% else %}Podział dzienny wyłączony{% endif %}</small><table class="table mt-2"><thead><tr><th>Okres</th><th>Pozycje</th><th>Detal</th><th>Do zapłaty</th></tr></thead><tbody>{% for key,row in invoices.items() %}<tr><td>{{key}}</td><td>{{row.count}}</td><td>{{'%.2f'|format(row.gross)}} zł</td><td>{{'%.2f'|format(row.payable)}} zł</td></tr>{% else %}<tr><td colspan="4">Brak danych</td></tr>{% endfor %}</tbody></table></div></div></div></div> <div class="col-6 col-xl-2"><div class="card metric h-100"><div class="card-body"><small>Do zapłaty brutto</small><strong>{{ '%.2f'|format(totals.invoice_gross) }} zł</strong><span>po rabatach i dopłatach</span></div></div></div>
<div class="card mt-4"><div class="card-body"><h2 class="h5">Tankowania</h2><div class="table-responsive"><table class="table table-hover align-middle"><thead><tr><th>Data</th><th>Auto</th><th>Stacja</th><th>Paliwo</th><th>Litry</th><th>Cena detal.</th><th>Orlen last</th><th>Do zapłaty</th><th>VAT</th><th>Razem</th></tr></thead><tbody>{% for e in entries %}{% set s=settlements[e.id] %}<tr><td>{{e.fueled_at.strftime('%d.%m.%Y')}}</td><td>{{e.vehicle.name}}</td><td>{{e.station or '—'}}</td><td>{{e.fuel_type}}</td><td>{{e.liters}}</td><td>{{'%.4f'|format(s.normal_price)}} zł/l</td><td>{% if e.wholesale_price %}{{'%.4f'|format(e.wholesale_price|float)}} zł/l{% else %}—{% endif %}</td><td>{% if s.uses_last_price %}<span class="badge text-bg-info">last price</span> {% endif %}{{'%.4f'|format(s.payable_price)}} zł/l</td><td>{{'%.2f'|format(s.vat_rate)}}%</td><td><span class="text-body-secondary text-decoration-line-through">{{'%.2f'|format(s.normal_gross)}} zł</span><br><strong>{{'%.2f'|format(s.payable_gross)}} zł</strong></td></tr>{% else %}<tr><td colspan="10">Brak tankowań w tym miesiącu.</td></tr>{% endfor %}</tbody></table></div></div></div> <div class="col-6 col-xl-2"><div class="card metric h-100"><div class="card-body"><small>Różnica brutto do detalu</small><strong>{{ '%.2f'|format(totals.gross_saving) }} zł</strong><span>kwota faktury vs detal</span></div></div></div>
<div class="col-6 col-xl-2"><div class="card metric h-100"><div class="card-body"><small>Faktury brutto</small><strong>{{ '%.2f'|format(totals.invoice_gross) }} zł</strong><span>netto + VAT firmy</span></div></div></div>
<div class="col-6 col-xl-2"><div class="card metric h-100"><div class="card-body"><small>Koszt po VAT</small><strong>{{ '%.2f'|format(totals.final_cost) }} zł</strong><span>po odliczeniu VAT</span></div></div></div>
<div class="col-6 col-xl-2"><div class="card metric h-100"><div class="card-body"><small>Litry / VAT odliczony</small><strong>{{ '%.2f'|format(totals.liters) }} l</strong><span>{{ '%.2f'|format(totals.deductible_vat) }} zł VAT</span></div></div></div>
</div>
<div class="alert alert-info small"><strong>Wykres brutto:</strong> porównuje wartość po cenie detalicznej z ostateczną kwotą faktury brutto. Cena rozliczeniowa uwzględnia Last Price albo cenę detaliczną netto, następnie rabat procentowy i dopłatę przypisaną do karty/stacji. Oszczędność handlowa Last Price nadal jest liczona netto.</div>
<div class="row g-4"><div class="col-lg-7"><div class="card"><div class="card-body"><h2 class="h5">Kwoty brutto według pojazdu</h2><p class="small text-body-secondary">Detal brutto, ostateczna kwota do zapłaty brutto oraz różnica. Rabaty i dopłaty użytkownika są uwzględniane.</p><div class="chart-box"><canvas id="cost-chart"></canvas><div id="cost-chart-empty" class="chart-empty d-none">Brak danych dla wybranego miesiąca.</div></div></div></div></div><div class="col-lg-5"><div class="card h-100"><div class="card-body"><h2 class="h5">Okresy faktur</h2><small class="text-body-secondary">{% if split_enabled %}Podział według ustawień firmy{% else %}Podział dzienny wyłączony{% endif %}</small><table class="table mt-2"><thead><tr><th>Okres</th><th>Pozycje</th><th>Faktura*</th><th>Koszt po VAT</th></tr></thead><tbody>{% for key,row in invoices.items() %}<tr><td>{{key}}</td><td>{{row.count}}</td><td>{{'%.2f'|format(row.gross)}} zł</td><td>{{'%.2f'|format(row.payable)}} zł</td></tr>{% else %}<tr><td colspan="4">Brak danych</td></tr>{% endfor %}</tbody></table><div class="small text-body-secondary">* Kwota brutto: cena rozliczeniowa netto + VAT właściwy dla firmy.</div></div></div></div>
<div class="card mt-4"><div class="card-body"><h2 class="h5">Tankowania</h2><div class="table-responsive"><table class="table table-hover align-middle"><thead><tr><th>Data</th><th>Auto</th><th>Stacja</th><th>Litry</th><th>Detal brutto</th><th>Detal netto</th><th>Last Price netto</th><th>Oszczędność netto</th><th>Faktura brutto</th><th>Koszt po VAT</th></tr></thead><tbody>{% for e in entries %}{% set s=settlements[e.id] %}<tr class="{% if e.vat_action_applied %}vat-action-row{% endif %}"{% if e.vat_action_applied %} title="Akcja: {{ e.vat_action_name }} · VAT {{ '%.2f'|format(e.vat_action_rate|float) }}%{% if e.vat_action_start or e.vat_action_end %} · {{ e.vat_action_start or 'bez daty początkowej' }}{{ e.vat_action_end or 'bez daty końcowej' }}{% endif %}"{% endif %}><td>{{e.fueled_at.strftime('%d.%m.%Y')}}{% if e.vat_action_applied %}<br><span class="badge vat-action-badge">{{e.vat_action_name or 'Akcja rządowa'}}</span>{% endif %}</td><td>{{e.vehicle.name}}</td><td>{{e.station or '—'}}</td><td>{{e.liters}}</td><td>{{'%.4f'|format(s.retail_price)}} zł/l<br><small>{{'%.2f'|format(s.retail_gross)}} zł</small></td><td>{{'%.4f'|format(s.retail_net_price)}} zł/l<br><small>{{'%.2f'|format(s.retail_net_total)}} zł</small></td><td>{% if s.uses_last_price %}<span class="badge text-bg-info">Last Price</span><br>{{'%.4f'|format(s.settlement_net)}} zł/l{% else %}{{'%.4f'|format(s.settlement_net)}} zł/l{% endif %}<br><small>{{'%.2f'|format(s.settlement_net_total)}} zł</small></td><td>{% if s.uses_last_price %}<strong>{{'%.2f'|format(s.saving_net)}} zł</strong><br><small>{{'%.4f'|format(s.saving_net_per_liter)}} zł/l</small>{% else %}—{% endif %}</td><td>{{'%.4f'|format(s.invoice_price)}} zł/l<br><strong>{{'%.2f'|format(s.invoice_gross)}} zł</strong><br><small>VAT {{'%.2f'|format(s.vat_rate)}}%</small></td><td>{{'%.4f'|format(s.effective_price)}} zł/l<br><strong>{{'%.2f'|format(s.effective_cost)}} zł</strong><br><small>odliczenie VAT {{'%.0f'|format(s.vat_deduction_percent)}}%</small></td></tr>{% else %}<tr><td colspan="10">Brak tankowań w tym miesiącu.</td></tr>{% endfor %}</tbody></table></div></div></div>
{% endblock %}{% block scripts %}<script>FuelTrack.renderBarChart('cost-chart', {{by_vehicle|tojson}});</script>{% endblock %} {% endblock %}{% block scripts %}<script>FuelTrack.renderBarChart('cost-chart', {{by_vehicle|tojson}});</script>{% endblock %}
+29 -1
View File
@@ -1 +1,29 @@
{% extends 'base.html' %}{% block title %}Logowanie FuelTrack{% endblock %}{% block content %}<div class="row justify-content-center"><div class="col-md-5 col-lg-4"><div class="card shadow-sm"><div class="card-body p-4"><h1 class="h3 mb-3">Logowanie</h1><form method="post" action="/api/auth/login" class="api-login-form" autocomplete="on"><label class="form-label">E-mail</label><input class="form-control mb-3" type="email" name="email" autocomplete="username" required><label class="form-label">Hasło</label><input class="form-control mb-3" type="password" name="password" autocomplete="current-password" required><button class="btn btn-primary w-100">Zaloguj</button></form></div></div></div></div>{% endblock %} {% extends 'base.html' %}
{% block title %}Logowanie FuelTrack{% endblock %}
{% block content %}
<div class="auth-shell">
<div class="auth-brand" aria-label="FuelTrack">
<span class="brand-fuel">Fuel</span><span class="brand-track">Track</span>
</div>
<div class="card auth-card">
<div class="card-body p-4 p-sm-5">
<div class="mb-4">
<div class="page-kicker">Panel użytkownika</div>
<h1 class="h3 mb-2">Zaloguj się</h1>
<p class="text-body-secondary mb-0">Wprowadź dane dostępowe do aplikacji.</p>
</div>
<form method="post" action="/api/auth/login" class="api-login-form" autocomplete="on">
<div class="mb-3">
<label class="form-label" for="login-email">E-mail</label>
<input class="form-control" id="login-email" type="email" name="email" autocomplete="username" required autofocus>
</div>
<div class="mb-4">
<label class="form-label" for="login-password">Hasło</label>
<input class="form-control" id="login-password" type="password" name="password" autocomplete="current-password" required>
</div>
<button class="btn btn-primary w-100">Zaloguj</button>
</form>
</div>
</div>
</div>
{% endblock %}
+61 -6
View File
@@ -1,8 +1,63 @@
{% extends 'base.html' %}{% block title %}Ceny Orlen{% endblock %}{% block content %} {% extends 'base.html' %}
<div class="mb-4"><h1 class="h2 mb-1">Dane cenowe Orlen</h1><p class="text-body-secondary mb-0">Porównuj kilka paliw na jednym wykresie i zapisuj dane w bazie.</p></div> {% block title %}Ceny Orlen{% endblock %}
<div class="card mb-4"><div class="card-body"><form method="get" id="orlen-filter-form" class="row g-3 align-items-end ajax-nav-form"><div class="col-lg-6"><label class="form-label d-block">Paliwa na wykresie</label><div class="d-flex flex-wrap gap-3">{% for f in fuel_types %}<label class="form-check"><input class="form-check-input" type="checkbox" name="fuel" value="{{f}}" {% if f in selected_fuels %}checked{% endif %}><span class="form-check-label">{{f}}</span></label>{% endfor %}</div></div><div class="col-lg-6"><label class="form-label d-block">Województwa LPG</label><button type="button" class="btn btn-secondary" data-bs-toggle="modal" data-bs-target="#lpgRegionsModal">Wybierz województwa</button> <span id="lpg-region-count" class="small text-body-secondary">{% if selected_regions %}{{selected_regions|length}} wybrano{% else %}Domyślne województwo{% endif %}</span></div><div class="col-md-2"><label class="form-label">Rok</label><input class="form-control" type="number" min="2020" max="2100" name="year" value="{{selected_year}}"></div><div class="col-md-2"><div class="form-check mb-2"><input class="form-check-input" type="checkbox" name="gross" value="1" id="gross-price" {% if include_vat %}checked{% endif %}><label class="form-check-label" for="gross-price">Dolicz VAT</label></div></div><div class="col-md-2"><button class="btn btn-secondary w-100">Pokaż</button></div></form>{% if current_user.role in ['boss','admin'] %}<div class="d-flex align-items-center gap-3 mt-3"><button type="button" id="orlen-sync-btn" class="btn btn-primary">Pobierz z API</button><span id="orlen-sync-status" class="small text-body-secondary" aria-live="polite"></span></div>{% endif %}</div></div> {% block content %}
<div class="page-header">
<div><div class="page-kicker">Analiza cen</div><h1 class="h2 mb-1">Dane cenowe Orlen</h1><p class="text-body-secondary mb-0">Przeglądaj historię i porównuj ceny tego samego paliwa między okresami.</p></div>
</div>
<ul class="nav nav-tabs mb-4" aria-label="Tryb analizy cen">
<li class="nav-item"><a class="nav-link {% if mode == 'history' %}active{% endif %}" href="{{ url_for('main.orlen_data', mode='history', year=selected_year) }}">Przegląd danych</a></li>
<li class="nav-item"><a class="nav-link {% if mode == 'compare' %}active{% endif %}" href="{{ url_for('main.orlen_data', mode='compare', year=selected_year, compare_year=compare_year, compare_fuel=compare_fuel) }}">Porównanie okresów</a></li>
</ul>
{% if mode == 'history' %}
<div class="card mb-4"><div class="card-body">
<form method="get" id="orlen-filter-form" class="row g-3 align-items-end ajax-nav-form">
<input type="hidden" name="mode" value="history">
<div class="col-lg-6"><label class="form-label d-block">Paliwa na wykresie</label><div class="d-flex flex-wrap gap-3">{% for f in fuel_types %}<label class="form-check"><input class="form-check-input" type="checkbox" name="fuel" value="{{f}}" {% if f in selected_fuels %}checked{% endif %}><span class="form-check-label">{{f}}</span></label>{% endfor %}</div></div>
<div class="col-lg-6"><label class="form-label d-block">Województwa LPG</label><button type="button" class="btn btn-secondary" data-bs-toggle="modal" data-bs-target="#lpgRegionsModal">Wybierz województwa</button> <span id="lpg-region-count" class="small text-body-secondary">{% if selected_regions %}{{selected_regions|length}} wybrano{% else %}Domyślne województwo{% endif %}</span></div>
<div class="col-md-3"><label class="form-label">Rok</label><select class="form-select" name="year">{% for year in available_years %}<option value="{{year}}" {% if year == selected_year %}selected{% endif %}>{{year}}</option>{% endfor %}</select></div>
<div class="col-md-3"><div class="form-check mb-2"><input class="form-check-input" type="checkbox" name="gross" value="1" id="gross-price" {% if include_vat %}checked{% endif %}><label class="form-check-label" for="gross-price">Dolicz VAT</label></div></div>
<div class="col-md-3"><button class="btn btn-secondary w-100">Pokaż dane</button></div>
</form>
{% if current_user.role in ['boss','admin'] %}
<div class="d-flex flex-wrap align-items-center gap-3 mt-3">
<div class="btn-group">
<button type="button" id="orlen-sync-btn" class="btn btn-primary">Pobierz nowe dane</button>
<button type="button" class="btn btn-primary dropdown-toggle dropdown-toggle-split" data-bs-toggle="dropdown" aria-expanded="false"><span class="visually-hidden">Opcje pobierania</span></button>
<ul class="dropdown-menu">
<li><button type="button" id="orlen-full-sync-btn" class="dropdown-item">Pobierz cały wybrany rok ponownie</button></li>
</ul>
</div>
<span id="orlen-sync-status" class="small text-body-secondary" aria-live="polite"></span>
</div>
{% endif %}
</div></div>
<div class="modal fade lpg-region-modal" id="lpgRegionsModal" tabindex="-1" aria-hidden="true"><div class="modal-dialog modal-lg modal-dialog-centered"><div class="modal-content"><div class="modal-header"><h2 class="modal-title fs-5">Województwa LPG</h2><button type="button" class="btn-close" data-bs-dismiss="modal"></button></div><div class="modal-body"><label class="form-label" for="lpg-region-select">Wybierz jedno lub wiele województw</label><select class="form-select" name="region" id="lpg-region-select" form="orlen-filter-form" multiple>{% for r in regions %}<option value="{{r}}" {% if r in selected_regions %}selected{% endif %}>{{r}}</option>{% endfor %}</select></div><div class="modal-footer"><button type="button" class="btn btn-primary" data-bs-dismiss="modal">Gotowe</button></div></div></div></div> <div class="modal fade lpg-region-modal" id="lpgRegionsModal" tabindex="-1" aria-hidden="true"><div class="modal-dialog modal-lg modal-dialog-centered"><div class="modal-content"><div class="modal-header"><h2 class="modal-title fs-5">Województwa LPG</h2><button type="button" class="btn-close" data-bs-dismiss="modal"></button></div><div class="modal-body"><label class="form-label" for="lpg-region-select">Wybierz jedno lub wiele województw</label><select class="form-select" name="region" id="lpg-region-select" form="orlen-filter-form" multiple>{% for r in regions %}<option value="{{r}}" {% if r in selected_regions %}selected{% endif %}>{{r}}</option>{% endfor %}</select></div><div class="modal-footer"><button type="button" class="btn btn-primary" data-bs-dismiss="modal">Gotowe</button></div></div></div></div>
<div class="card mb-4"><div class="card-body"><h2 class="h5">Trend ceny {% if include_vat %}brutto{% else %}netto{% endif %} za litr</h2><div class="chart-wrap"><canvas id="orlen-chart"></canvas><div id="orlen-chart-empty" class="chart-empty d-none">Brak danych dla wybranych filtrów.</div></div></div></div> {% else %}
<div class="card"><div class="card-body"><div class="table-responsive"><table class="table table-hover align-middle"><thead><tr><th>Data</th><th>Paliwo</th><th>Cena netto zł/l</th><th>Region</th><th>Pobrano</th></tr></thead><tbody>{% for r in rows|reverse %}<tr><td>{{r.effective_date}}</td><td>{{r.product_name}}</td><td>{{'%.4f'|format(r.price_per_liter|float)}}</td><td>{{r.region or '—'}}</td><td>{{r.fetched_at.strftime('%Y-%m-%d %H:%M')}}</td></tr>{% else %}<tr><td colspan="5" class="text-center text-body-secondary py-4">Brak danych w bazie.</td></tr>{% endfor %}</tbody></table></div></div></div> <div class="card mb-4"><div class="card-body">
{% endblock %}{% block scripts %}<script>FuelTrack.renderMultiLineChart('orlen-chart', {{chart|tojson}}, {{include_vat|tojson}});FuelTrack.bindOrlenSync();</script>{% endblock %} <form method="get" id="orlen-filter-form" class="row g-3 align-items-end ajax-nav-form">
<input type="hidden" name="mode" value="compare">
<div class="col-md-4"><label class="form-label">Paliwo</label><select class="form-select" name="compare_fuel" id="compare-fuel">{% for f in fuel_types %}<option value="{{f}}" {% if f == compare_fuel %}selected{% endif %}>{{f}}</option>{% endfor %}</select><div class="form-text">Porównanie zawsze dotyczy jednego rodzaju paliwa.</div></div>
<div class="col-md-4 {% if compare_fuel != 'LPG' %}d-none{% endif %}" id="compare-region-wrap"><label class="form-label">Województwo LPG</label><select class="form-select" name="compare_region">{% for r in regions %}<option value="{{r}}" {% if r == compare_region %}selected{% endif %}>{{r}}</option>{% endfor %}</select></div>
<div class="col-md-4"><label class="form-label">Rodzaj porównania</label><select class="form-select" name="comparison_type" id="comparison-type"><option value="years" {% if comparison_type == 'years' %}selected{% endif %}>Rok do roku</option><option value="periods" {% if comparison_type == 'periods' %}selected{% endif %}>Dowolne okresy</option></select></div>
<div class="col-12"><div id="year-comparison-fields" class="row g-3 {% if comparison_type != 'years' %}d-none{% endif %}"><div class="col-md-4"><label class="form-label">Rok A</label><select class="form-select" name="year">{% for year in available_years %}<option value="{{year}}" {% if year == selected_year %}selected{% endif %}>{{year}}</option>{% endfor %}</select></div><div class="col-md-4"><label class="form-label">Rok B</label><select class="form-select" name="compare_year">{% for year in available_years %}<option value="{{year}}" {% if year == compare_year %}selected{% endif %}>{{year}}</option>{% endfor %}</select></div></div></div>
<div class="col-12"><div id="period-comparison-fields" class="row g-3 {% if comparison_type != 'periods' %}d-none{% endif %}"><div class="col-md-3"><label class="form-label">Okres A od</label><input class="form-control" type="date" name="period_a_from" value="{{period_a_from.isoformat() if period_a_from else ''}}"></div><div class="col-md-3"><label class="form-label">Okres A do</label><input class="form-control" type="date" name="period_a_to" value="{{period_a_to.isoformat() if period_a_to else ''}}"></div><div class="col-md-3"><label class="form-label">Okres B od</label><input class="form-control" type="date" name="period_b_from" value="{{period_b_from.isoformat() if period_b_from else ''}}"></div><div class="col-md-3"><label class="form-label">Okres B do</label><input class="form-control" type="date" name="period_b_to" value="{{period_b_to.isoformat() if period_b_to else ''}}"></div></div></div>
<div class="col-md-3"><div class="form-check mb-2"><input class="form-check-input" type="checkbox" name="gross" value="1" id="gross-price" {% if include_vat %}checked{% endif %}><label class="form-check-label" for="gross-price">Dolicz VAT</label></div></div>
<div class="col-md-3"><button class="btn btn-primary w-100">Porównaj</button></div>
</form>
</div></div>
{% if comparison_summary %}<div class="row g-3 mb-4">{% for item in comparison_summary %}<div class="col-md-6"><div class="card metric h-100"><div class="card-body"><small>{{item.label}}</small><strong>{% if item.average is not none %}{{'%.4f'|format(item.average)}} zł/l{% else %}Brak danych{% endif %}</strong>{% if item.count %}<div class="small text-body-secondary mt-2">Min. {{'%.4f'|format(item.minimum)}} · Maks. {{'%.4f'|format(item.maximum)}} · {{item.count}} pomiarów</div>{% endif %}</div></div></div>{% endfor %}</div>{% endif %}
{% if summary_difference %}<div class="alert {% if summary_difference.value > 0 %}alert-warning{% elif summary_difference.value < 0 %}alert-success{% else %}alert-secondary{% endif %} mb-4">Średnia w pierwszym okresie jest <strong>{% if summary_difference.value > 0 %}wyższa{% elif summary_difference.value < 0 %}niższa{% else %}taka sama{% endif %}</strong> o {{'%.4f'|format(summary_difference.value|abs)}} zł/l{% if summary_difference.percent is not none %} ({{'%.2f'|format(summary_difference.percent|abs)}}%){% endif %}.</div>{% endif %}
{% endif %}
<div class="card mb-4"><div class="card-body"><div class="section-heading"><div><h2 class="h5 mb-1">{% if mode == 'compare' %}Porównanie: {{compare_fuel}}{% else %}Trend ceny{% endif %} {% if include_vat %}brutto{% else %}netto{% endif %}</h2><p class="small text-body-secondary mb-0">Cena za litr w złotych.</p></div></div><div class="chart-wrap"><canvas id="orlen-chart"></canvas><div id="orlen-chart-empty" class="chart-empty d-none">Brak danych dla wybranych filtrów.</div></div></div></div>
{% if mode == 'history' %}<div class="card"><div class="card-body"><div class="table-responsive"><table class="table table-hover align-middle"><thead><tr><th>Data</th><th>Paliwo</th><th>Cena netto zł/l</th><th>Region</th><th>Pobrano</th></tr></thead><tbody>{% for r in rows|reverse %}<tr><td>{{r.effective_date}}</td><td>{{r.product_name}}</td><td>{{'%.4f'|format(r.price_per_liter|float)}}</td><td>{{r.region or '—'}}</td><td>{{r.fetched_at.strftime('%Y-%m-%d %H:%M')}}</td></tr>{% else %}<tr><td colspan="5" class="text-center text-body-secondary py-4">Brak danych w bazie.</td></tr>{% endfor %}</tbody></table></div></div></div>{% endif %}
{% endblock %}
{% block scripts %}<script>FuelTrack.renderMultiLineChart('orlen-chart', {{chart|tojson}}, {{include_vat|tojson}});FuelTrack.bindOrlenSync();FuelTrack.bindOrlenComparison();</script>{% endblock %}
+30 -1
View File
@@ -16,10 +16,39 @@
<td>{{s.station_count}}</td> <td>{{s.station_count}}</td>
<td><div class="d-flex gap-1 flex-wrap">{% if s.has_petrol %}<span class="badge text-bg-secondary">benzyna</span>{% endif %}{% if s.has_diesel %}<span class="badge text-bg-secondary">diesel</span>{% endif %}{% if s.has_lpg %}<span class="badge text-bg-secondary">LPG</span>{% endif %}</div></td> <td><div class="d-flex gap-1 flex-wrap">{% if s.has_petrol %}<span class="badge text-bg-secondary">benzyna</span>{% endif %}{% if s.has_diesel %}<span class="badge text-bg-secondary">diesel</span>{% endif %}{% if s.has_lpg %}<span class="badge text-bg-secondary">LPG</span>{% endif %}</div></td>
<td><form method="post" action="/api/stations/{{s.id}}" data-method="PUT" class="ajax-form row g-2"><input type="hidden" name="company_id" value="{{ company.id }}"><div class="col-12"><div class="form-check"><input class="form-check-input" type="checkbox" name="active" id="active-{{s.id}}" {% if s.active %}checked{% endif %}><label class="form-check-label" for="active-{{s.id}}">Pokazuj w propozycjach</label></div><div class="form-check"><input class="form-check-input" type="checkbox" name="allowed" id="allowed-{{s.id}}" {% if s.id in allowed_ids %}checked{% endif %}><label class="form-check-label" for="allowed-{{s.id}}">Dodaj do zamkniętej listy firmy</label></div></div><div class="col-12"><button class="btn btn-primary btn-sm">Zapisz</button></div></form></td> <td><form method="post" action="/api/stations/{{s.id}}" data-method="PUT" class="ajax-form row g-2"><input type="hidden" name="company_id" value="{{ company.id }}"><div class="col-12"><div class="form-check"><input class="form-check-input" type="checkbox" name="active" id="active-{{s.id}}" {% if s.active %}checked{% endif %}><label class="form-check-label" for="active-{{s.id}}">Pokazuj w propozycjach</label></div><div class="form-check"><input class="form-check-input" type="checkbox" name="allowed" id="allowed-{{s.id}}" {% if s.id in allowed_ids %}checked{% endif %}><label class="form-check-label" for="allowed-{{s.id}}">Dodaj do zamkniętej listy firmy</label></div></div><div class="col-12"><button class="btn btn-primary btn-sm">Zapisz</button></div></form></td>
<td>{% for card in fuel_cards %}{% set rule=card_rules.get((card.id,s.id)) %}<form action="/api/fuel-cards/{{card.id}}/stations/{{s.id}}" data-method="PUT" class="ajax-form border rounded p-2 mb-2"><div class="fw-semibold small mb-2">{{card.name}}</div><div class="row g-2"><div class="col-12"><div class="form-check"><input class="form-check-input" type="checkbox" name="allowed" id="rule-allowed-{{card.id}}-{{s.id}}" {% if not rule or rule.allowed %}checked{% endif %}><label class="form-check-label" for="rule-allowed-{{card.id}}-{{s.id}}">Dozwolona dla karty</label></div><div class="form-check"><input class="form-check-input" type="checkbox" name="use_orlen_last_price" id="rule-last-{{card.id}}-{{s.id}}" {% if rule and rule.use_orlen_last_price %}checked{% endif %}><label class="form-check-label" for="rule-last-{{card.id}}-{{s.id}}">Orlen last price</label></div></div><div class="col-6"><label class="form-label small">Rabat netto %</label><input class="form-control form-control-sm" type="number" step="0.001" name="discount_net_percent" value="{{rule.discount_net_percent if rule else 0}}"></div><div class="col-6"><label class="form-label small">Dopłata netto/l</label><input class="form-control form-control-sm" type="number" step="0.0001" name="surcharge_net_per_liter" value="{{rule.surcharge_net_per_liter if rule else 0}}"></div><div class="col-12"><button class="btn btn-primary btn-sm">Zapisz warunki</button></div></div></form>{% else %}<span class="text-body-secondary small">Najpierw dodaj kartę w ustawieniach firmy.</span>{% endfor %}</td> <td class="text-nowrap">{% if fuel_cards %}<div class="d-flex flex-column align-items-start gap-1"><span class="small text-body-secondary">{{ fuel_cards|length }} {% if fuel_cards|length == 1 %}karta{% elif fuel_cards|length in [2,3,4] %}karty{% else %}kart{% endif %}</span><button type="button" class="btn btn-outline-primary btn-sm" data-bs-toggle="modal" data-bs-target="#stationCardRulesModal-{{s.id}}">Edytuj warunki</button></div>{% else %}<span class="text-body-secondary small">Brak kart</span>{% endif %}</td>
</tr>{% else %}<tr><td colspan="5" class="text-center text-body-secondary py-5">Brak firm. Pobierz katalog z URE.</td></tr>{% endfor %} </tr>{% else %}<tr><td colspan="5" class="text-center text-body-secondary py-5">Brak firm. Pobierz katalog z URE.</td></tr>{% endfor %}
</tbody></table></div> </tbody></table></div>
{% if station_page.pages > 1 %}<nav><ul class="pagination mb-0">{% for p in station_page.iter_pages() %}{% if p %}<li class="page-item {% if p==station_page.page %}active{% endif %}"><a class="page-link ajax-nav-link" href="{{url_for('main.stations',company_id=company.id,page=p,q=q,sort=sort,direction=direction)}}">{{p}}</a></li>{% else %}<li class="page-item disabled"><span class="page-link"></span></li>{% endif %}{% endfor %}</ul></nav>{% endif %} {% if station_page.pages > 1 %}<nav><ul class="pagination mb-0">{% for p in station_page.iter_pages() %}{% if p %}<li class="page-item {% if p==station_page.page %}active{% endif %}"><a class="page-link ajax-nav-link" href="{{url_for('main.stations',company_id=company.id,page=p,q=q,sort=sort,direction=direction)}}">{{p}}</a></li>{% else %}<li class="page-item disabled"><span class="page-link"></span></li>{% endif %}{% endfor %}</ul></nav>{% endif %}
</div></div> </div></div>
{% for s in station_page.items %}{% if fuel_cards %}
<div class="modal fade" id="stationCardRulesModal-{{s.id}}" tabindex="-1" aria-labelledby="stationCardRulesTitle-{{s.id}}" aria-hidden="true">
<div class="modal-dialog modal-xl modal-dialog-scrollable">
<div class="modal-content">
<div class="modal-header">
<div><h2 class="modal-title fs-5" id="stationCardRulesTitle-{{s.id}}">Warunki kart — {{s.display_name}}</h2><div class="small text-body-secondary">{{ fuel_cards|length }} {% if fuel_cards|length == 1 %}karta paliwowa{% elif fuel_cards|length in [2,3,4] %}karty paliwowe{% else %}kart paliwowych{% endif %}</div></div>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Zamknij"></button>
</div>
<div class="modal-body">
<div class="row g-3">
{% for card in fuel_cards %}{% set rule=card_rules.get((card.id,s.id)) %}
<div class="col-12 col-lg-6">
<form action="/api/fuel-cards/{{card.id}}/stations/{{s.id}}" data-method="PUT" class="ajax-form border rounded p-3 h-100">
<div class="d-flex justify-content-between align-items-start gap-2 mb-3"><div><div class="fw-semibold">{{card.name}}</div><div class="small text-body-secondary">{{card.provider}}</div></div>{% if not rule or rule.allowed %}<span class="badge text-bg-success">Dozwolona</span>{% else %}<span class="badge text-bg-secondary">Zablokowana</span>{% endif %}</div>
<div class="row g-3">
<div class="col-12"><div class="form-check"><input class="form-check-input" type="checkbox" name="allowed" id="rule-allowed-{{card.id}}-{{s.id}}" {% if not rule or rule.allowed %}checked{% endif %}><label class="form-check-label" for="rule-allowed-{{card.id}}-{{s.id}}">Dozwolona dla karty</label></div><div class="form-check"><input class="form-check-input" type="checkbox" name="use_orlen_last_price" id="rule-last-{{card.id}}-{{s.id}}" {% if rule and rule.use_orlen_last_price %}checked{% endif %}><label class="form-check-label" for="rule-last-{{card.id}}-{{s.id}}">Orlen last price</label></div></div>
<div class="col-sm-6"><label class="form-label small">Rabat netto %</label><input class="form-control form-control-sm" type="number" step="0.001" name="discount_net_percent" value="{{rule.discount_net_percent if rule else 0}}"></div>
<div class="col-sm-6"><label class="form-label small">Dopłata netto/l</label><input class="form-control form-control-sm" type="number" step="0.0001" name="surcharge_net_per_liter" value="{{rule.surcharge_net_per_liter if rule else 0}}"></div>
<div class="col-12"><button class="btn btn-primary btn-sm">Zapisz warunki</button></div>
</div>
</form>
</div>
{% endfor %}
</div>
</div>
</div>
</div>
</div>
{% endif %}{% endfor %}
<div class="modal fade" id="stationPointsModal" tabindex="-1" aria-hidden="true"><div class="modal-dialog modal-xl modal-dialog-scrollable"><div class="modal-content"><div class="modal-header"><div><h2 class="modal-title fs-5" id="stationPointsTitle">Punkty stacji</h2><div class="small text-body-secondary" id="stationPointsCount"></div></div><button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Zamknij"></button></div><div class="modal-body"><div class="input-group mb-3"><span class="input-group-text">Szukaj</span><input class="form-control" id="stationPointsSearch" placeholder="Nazwa, ulica, miejscowość lub kod pocztowy"></div><div id="stationPointsLoading" class="text-center py-5 d-none"><div class="spinner-border" role="status"></div></div><div class="table-responsive"><table class="table table-hover align-middle mb-0"><thead><tr><th>Nazwa punktu</th><th>Adres</th><th>Województwo</th><th>Paliwa</th></tr></thead><tbody id="stationPointsRows"></tbody></table></div><div id="stationPointsEmpty" class="text-center text-body-secondary py-5 d-none">Brak punktów do wyświetlenia. Wykonaj ponowny import z URE.</div></div></div></div></div> <div class="modal fade" id="stationPointsModal" tabindex="-1" aria-hidden="true"><div class="modal-dialog modal-xl modal-dialog-scrollable"><div class="modal-content"><div class="modal-header"><div><h2 class="modal-title fs-5" id="stationPointsTitle">Punkty stacji</h2><div class="small text-body-secondary" id="stationPointsCount"></div></div><button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Zamknij"></button></div><div class="modal-body"><div class="input-group mb-3"><span class="input-group-text">Szukaj</span><input class="form-control" id="stationPointsSearch" placeholder="Nazwa, ulica, miejscowość lub kod pocztowy"></div><div id="stationPointsLoading" class="text-center py-5 d-none"><div class="spinner-border" role="status"></div></div><div class="table-responsive"><table class="table table-hover align-middle mb-0"><thead><tr><th>Nazwa punktu</th><th>Adres</th><th>Województwo</th><th>Paliwa</th></tr></thead><tbody id="stationPointsRows"></tbody></table></div><div id="stationPointsEmpty" class="text-center text-body-secondary py-5 d-none">Brak punktów do wyświetlenia. Wykonaj ponowny import z URE.</div></div></div></div></div>
{% endblock %} {% endblock %}
+45 -1
View File
@@ -1,6 +1,5 @@
from datetime import date, datetime from datetime import date, datetime
from decimal import Decimal, InvalidOperation from decimal import Decimal, InvalidOperation
from .extensions import db from .extensions import db
from .models import AppSetting from .models import AppSetting
@@ -56,6 +55,51 @@ def effective_vat_rate(nominal_rate, on_date=None):
return override["rate"] return override["rate"]
def vat_override_for_date(nominal_rate, on_date=None):
"""Return a complete, immutable VAT-action snapshot for a date."""
override = global_vat_override()
if isinstance(on_date, datetime):
on_date = on_date.date()
on_date = on_date or date.today()
applies = bool(
override["enabled"]
and override["rate"] is not None
and (not override["start"] or on_date >= override["start"])
and (not override["end"] or on_date <= override["end"])
)
return {
"applied": applies,
"name": override["name"] if applies else None,
"rate": override["rate"] if applies else Decimal(str(nominal_rate)),
"start": override["start"] if applies else None,
"end": override["end"] if applies else None,
}
def freeze_vat_action(entry, nominal_rate, previous_fueled_at=None):
"""Attach VAT action conditions to an entry without rewriting valid history."""
fueled_date = entry.fueled_at.date() if isinstance(entry.fueled_at, datetime) else entry.fueled_at
previous_date = previous_fueled_at.date() if isinstance(previous_fueled_at, datetime) else previous_fueled_at
if entry.vat_action_frozen and (previous_date is None or previous_date == fueled_date):
return
snapshot = vat_override_for_date(nominal_rate, fueled_date)
entry.vat_action_frozen = True
entry.vat_action_applied = snapshot["applied"]
entry.vat_action_name = snapshot["name"]
entry.vat_action_rate = snapshot["rate"] if snapshot["applied"] else None
entry.vat_action_start = snapshot["start"]
entry.vat_action_end = snapshot["end"]
def entry_vat_rate(entry, nominal_rate):
if entry.vat_action_frozen:
if entry.vat_action_applied and entry.vat_action_rate is not None:
return Decimal(str(entry.vat_action_rate))
return Decimal(str(nominal_rate))
return effective_vat_rate(nominal_rate, entry.fueled_at)
def save_global_vat_override(form): def save_global_vat_override(form):
enabled = "enabled" in form enabled = "enabled" in form
name = (form.get("name") or "Projekt rządowy").strip() name = (form.get("name") or "Projekt rządowy").strip()
Executable
+53
View File
@@ -0,0 +1,53 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
APP_DIR="${APP_DIR:-$SCRIPT_DIR}"
INSTANCE_DIR="${INSTANCE_DIR:-$APP_DIR/instance}"
ENV_FILE="${ENV_FILE:-$APP_DIR/.env}"
DB_FILE="${DB_FILE:-$INSTANCE_DIR/fueltrack.db}"
# Uruchamiaj aplikację zawsze z jej właściwego katalogu.
cd "$APP_DIR"
# Wczytaj .env niezależnie od katalogu wywołania skryptu.
if [[ -f "$ENV_FILE" ]]; then
set -a
# shellcheck disable=SC1090
source "$ENV_FILE"
set +a
fi
mkdir -p "$INSTANCE_DIR"
mkdir -p "$(dirname -- "$DB_FILE")"
export FLASK_ENV="${FLASK_ENV:-development}"
export APP_HOST="${APP_HOST:-0.0.0.0}"
export APP_PORT="${APP_PORT:-8000}"
export GUNICORN_WORKERS="${GUNICORN_WORKERS:-1}"
export GUNICORN_THREADS="${GUNICORN_THREADS:-8}"
export GUNICORN_TIMEOUT="${GUNICORN_TIMEOUT:-0}"
export GUNICORN_APP="${GUNICORN_APP:-wsgi:app}"
# Jeśli DATABASE_URL nie jest ustawione w .env, użyj bezwzględnej
# ścieżki do pliku SQLite.
if [[ -z "${DATABASE_URL:-}" ]]; then
export DATABASE_URL="sqlite:///$DB_FILE"
fi
echo "FuelTrack DEV"
echo "Katalog aplikacji: $APP_DIR"
echo "Adres: http://localhost:${APP_PORT}"
echo "Baza: $DATABASE_URL"
exec gunicorn \
--chdir "$APP_DIR" \
--reload \
--access-logfile - \
--error-logfile - \
--worker-class gthread \
--workers "$GUNICORN_WORKERS" \
--threads "$GUNICORN_THREADS" \
--timeout "$GUNICORN_TIMEOUT" \
--bind "$APP_HOST:$APP_PORT" \
"$GUNICORN_APP"
-3
View File
@@ -1,9 +1,6 @@
Flask>=3.1.1 Flask>=3.1.1
Flask-SQLAlchemy>=3.1.1 Flask-SQLAlchemy>=3.1.1
Flask-Login>=0.6.3 Flask-Login>=0.6.3
Flask-SocketIO>=5.5.1
simple-websocket>=1.1.0
requests>=2.32.4 requests>=2.32.4
gunicorn>=23.0.0 gunicorn>=23.0.0
pytest>=8.4.1 pytest>=8.4.1
+237 -3
View File
@@ -93,9 +93,9 @@ def test_html_cache_headers_and_static_versioning(tmp_path):
app=make_app(tmp_path, "cache.db") app=make_app(tmp_path, "cache.db")
client=app.test_client() client=app.test_client()
response=client.get('/login') response=client.get('/login')
assert response.headers['Cache-Control'] == 'no-store, no-cache, private, must-revalidate' assert response.headers['Cache-Control'] == 'no-store, private, must-revalidate'
assert response.headers['Pragma'] == 'no-cache' assert 'Pragma' not in response.headers
assert response.headers['Expires'] == '0' assert 'Expires' not in response.headers
html=response.get_data(as_text=True) html=response.get_data(as_text=True)
assert '/static/css/layout.css?v=' in html assert '/static/css/layout.css?v=' in html
assert '/static/js/core.js?v=' in html assert '/static/js/core.js?v=' in html
@@ -316,3 +316,237 @@ def test_cannot_delete_assigned_fuel_card(tmp_path):
assert response.status_code == 409 assert response.status_code == 409
assert response.json['ok'] is False assert response.json['ok'] is False
assert 'użytkowników' in response.json['message'] 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</td>' not in html
def test_last_price_uses_only_non_deductible_vat():
from app.domain.costs import calculate_net_price_costs
result = calculate_net_price_costs(5.484, 23, 50)
assert round(float(result["invoice_gross"]), 4) == 6.7453
assert round(float(result["effective_cost"]), 4) == 6.1147
def test_last_price_zero_and_full_vat_deduction():
from app.domain.costs import calculate_net_price_costs
assert round(float(calculate_net_price_costs(10, 23, 0)["effective_cost"]), 2) == 12.30
assert round(float(calculate_net_price_costs(10, 23, 100)["effective_cost"]), 2) == 10.00
def test_fuel_entry_edit_modal_uses_scrollable_bootstrap_structure(tmp_path):
app = make_app(tmp_path, "fuel-entry-modal.db")
client = app.test_client()
client.post('/login', data={'email': 'admin@example.com', 'password': 'admin123!'})
html = client.get('/admin/fuel-entries').get_data(as_text=True)
assert (
'modal-dialog modal-lg modal-dialog-scrollable">'
'<form id="fuel-entry-edit-form" class="modal-content ajax-form"'
in html
)
assert '<div class="modal-content"><form id="fuel-entry-edit-form"' not in html
def test_orlen_incremental_sync_starts_at_latest_saved_record(tmp_path, monkeypatch):
from datetime import date
from decimal import Decimal
from app.extensions import db
from app.models import OrlenPrice
from app.orlen_sync import sync_orlen_prices
import app.orlen_sync as orlen_sync_module
app = make_app(tmp_path, "orlen-incremental.db")
calls = []
def fake_fetch(fuel, date_from, date_to, region):
calls.append((fuel, date_from, date_to, region))
return []
monkeypatch.setattr(orlen_sync_module, 'fetch_orlen_range', fake_fetch)
with app.app_context():
db.session.add_all([
OrlenPrice(fuel_type='PB95', effective_date=date(2026, 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, 7, 15), price_per_liter=Decimal('4.5000'), raw_value=Decimal('4500'), region='', product_name='PB95', source='test'),
])
db.session.commit()
result, total = sync_orlen_prices(
fuels=['PB95'],
year=2026,
regions=[],
default_region='mazowieckie',
full_refresh=False,
today=date(2026, 8, 2),
)
assert calls == [('PB95', date(2026, 7, 15), date(2026, 8, 2), 'mazowieckie')]
assert result['PB95']['from'] == '2026-07-15'
assert result['PB95']['full_refresh'] is False
assert total == 0
def test_orlen_full_refresh_starts_at_beginning_of_selected_year(tmp_path, monkeypatch):
from datetime import date
from decimal import Decimal
from app.extensions import db
from app.models import OrlenPrice
from app.orlen_sync import sync_orlen_prices
import app.orlen_sync as orlen_sync_module
app = make_app(tmp_path, "orlen-full-refresh.db")
calls = []
def fake_fetch(fuel, date_from, date_to, region):
calls.append((fuel, date_from, date_to, region))
return []
monkeypatch.setattr(orlen_sync_module, 'fetch_orlen_range', fake_fetch)
with app.app_context():
db.session.add(OrlenPrice(fuel_type='DIESEL', effective_date=date(2026, 7, 20), price_per_liter=Decimal('4.9000'), raw_value=Decimal('4900'), region='', product_name='DIESEL', source='test'))
db.session.commit()
result, _ = sync_orlen_prices(
fuels=['DIESEL'],
year=2026,
regions=[],
default_region='mazowieckie',
full_refresh=True,
today=date(2026, 8, 2),
)
assert calls == [('DIESEL', date(2026, 1, 1), date(2026, 8, 2), 'mazowieckie')]
assert result['DIESEL']['from'] == '2026-01-01'
assert result['DIESEL']['full_refresh'] is True
def test_orlen_lpg_incremental_sync_uses_oldest_latest_region_date(tmp_path, monkeypatch):
from datetime import date
from decimal import Decimal
from app.extensions import db
from app.models import OrlenPrice
from app.orlen_sync import sync_orlen_prices
import app.orlen_sync as orlen_sync_module
app = make_app(tmp_path, "orlen-lpg-incremental.db")
calls = []
def fake_fetch(fuel, date_from, date_to, region):
calls.append((fuel, date_from, date_to, region))
return [
{'fuel_type': 'LPG', 'effective_date': date(2026, 8, 1), 'price_per_liter': Decimal('2.1000'), 'raw_value': Decimal('2.1000'), 'region': 'mazowieckie', 'product_name': 'LPG', 'source': 'test'},
{'fuel_type': 'LPG', 'effective_date': date(2026, 8, 1), 'price_per_liter': Decimal('2.2000'), 'raw_value': Decimal('2.2000'), 'region': 'pomorskie', 'product_name': 'LPG', 'source': 'test'},
]
monkeypatch.setattr(orlen_sync_module, 'fetch_orlen_range', fake_fetch)
with app.app_context():
db.session.add_all([
OrlenPrice(fuel_type='LPG', effective_date=date(2026, 7, 20), price_per_liter=Decimal('2.0000'), raw_value=Decimal('2.0000'), region='mazowieckie', product_name='LPG', source='test'),
OrlenPrice(fuel_type='LPG', effective_date=date(2026, 7, 25), price_per_liter=Decimal('2.0000'), raw_value=Decimal('2.0000'), region='śląskie', product_name='LPG', source='test'),
])
db.session.commit()
result, total = sync_orlen_prices(
fuels=['LPG'],
year=2026,
regions=['mazowieckie', 'śląskie'],
default_region='mazowieckie',
full_refresh=False,
today=date(2026, 8, 2),
)
db.session.commit()
assert OrlenPrice.query.filter_by(fuel_type='LPG', region='pomorskie').count() == 0
assert calls == [('LPG', date(2026, 7, 20), date(2026, 8, 2), 'all')]
assert result['LPG']['from'] == '2026-07-20'
assert total == 1
def test_orlen_page_has_incremental_and_full_refresh_controls(tmp_path):
app = make_app(tmp_path, "orlen-sync-ui.db")
client = app.test_client()
client.post('/login', data={'email': 'admin@example.com', 'password': 'admin123!'})
html = client.get('/orlen?mode=history').get_data(as_text=True)
assert 'id="orlen-sync-btn"' in html
assert 'Pobierz nowe dane' in html
assert 'id="orlen-full-sync-btn"' in html
assert 'Pobierz cały wybrany rok ponownie' in html
def test_orlen_period_comparison_adds_effective_vat_for_each_date(tmp_path):
from datetime import date
from decimal import Decimal
from app.extensions import db
from app.models import AppSetting, OrlenPrice
app = make_app(tmp_path, "orlen-period-vat.db")
client = app.test_client()
client.post('/login', data={'email': 'admin@example.com', 'password': 'admin123!'})
with app.app_context():
for key, value in {
'global_vat_override_enabled': '1',
'global_vat_override_name': 'Czasowa stawka',
'global_vat_override_rate': '8',
'global_vat_override_start': '2026-07-01',
'global_vat_override_end': '2026-07-31',
}.items():
db.session.merge(AppSetting(key=key, value=value))
db.session.add_all([
OrlenPrice(
fuel_type='PB95', effective_date=date(2026, 7, 15),
price_per_liter=Decimal('100.0000'), raw_value=Decimal('100000'),
region='', product_name='PB95', source='test',
),
OrlenPrice(
fuel_type='PB95', effective_date=date(2026, 8, 1),
price_per_liter=Decimal('100.0000'), raw_value=Decimal('100000'),
region='', product_name='PB95', source='test',
),
])
db.session.commit()
response = client.get(
'/orlen?mode=compare&comparison_type=periods&compare_fuel=PB95&gross=1'
'&period_a_from=2026-07-01&period_a_to=2026-07-31'
'&period_b_from=2026-08-01&period_b_to=2026-08-31'
)
assert response.status_code == 200
html = response.get_data(as_text=True)
assert '108.0000 zł/l' in html
assert '123.0000 zł/l' in html