Compare commits
17
Commits
de7153e298
..
master
+76
-17
@@ -9,21 +9,53 @@ 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":"Ciemny", "url":app.config["THEME_BOOTSTRAP_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)
|
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, 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"]}}
|
"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
|
||||||
|
|||||||
-518
@@ -1,518 +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
|
|
||||||
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(); 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ń')
|
|
||||||
|
|
||||||
@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();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();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()
|
|
||||||
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();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')
|
|
||||||
|
|
||||||
@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')
|
|
||||||
|
|
||||||
@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}')
|
|
||||||
|
|
||||||
@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')
|
|
||||||
@@ -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
|
||||||
@@ -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))
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
@@ -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')
|
||||||
@@ -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')
|
||||||
@@ -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)
|
||||||
@@ -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)
|
||||||
@@ -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')
|
||||||
@@ -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}')
|
||||||
@@ -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')
|
||||||
@@ -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ń')
|
||||||
+49
-10
@@ -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"))
|
|
||||||
flash("Nieprawidłowy e-mail lub hasło", "danger")
|
|
||||||
return render_template("login.html")
|
|
||||||
|
|
||||||
@auth.route("/logout")
|
if request.method == "GET":
|
||||||
|
return render_template("login.html")
|
||||||
|
|
||||||
|
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
-1
@@ -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)
|
||||||
|
|||||||
+10
-4
@@ -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"]
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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,
|
||||||
|
}
|
||||||
|
|||||||
+494
-58
@@ -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
|
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")
|
||||||
@@ -43,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 0–100%")
|
||||||
|
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 0–100%")
|
||||||
|
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()
|
||||||
@@ -60,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"])
|
||||||
@@ -172,7 +309,19 @@ 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)
|
||||||
|
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"))
|
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)
|
||||||
@@ -212,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()]
|
||||||
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 = []
|
||||||
chart = {}
|
chart = {}
|
||||||
for fuel in selected_fuels:
|
comparison_summary = []
|
||||||
fuel_rows = [r for r in rows if r.fuel_type == fuel and (fuel != "LPG" or r.region in selected_regions)]
|
comparison_type = request.args.get("comparison_type", "years")
|
||||||
chart_key = fuel if fuel != "LPG" else None
|
compare_fuel = request.args.get("compare_fuel", "PB95").upper()
|
||||||
if fuel == "LPG":
|
if compare_fuel not in FUEL_TYPES:
|
||||||
for region in selected_regions:
|
compare_fuel = "PB95"
|
||||||
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]
|
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:
|
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]
|
def parse_date_arg(name, fallback):
|
||||||
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)
|
try:
|
||||||
|
return date.fromisoformat(request.args.get(name, ""))
|
||||||
|
except ValueError:
|
||||||
|
return fallback
|
||||||
|
|
||||||
|
period_a_from = parse_date_arg("period_a_from", today.replace(month=1, day=1))
|
||||||
|
period_a_to = parse_date_arg("period_a_to", today)
|
||||||
|
previous_year = today.year - 1
|
||||||
|
period_b_from = parse_date_arg("period_b_from", date(previous_year, period_a_from.month, min(period_a_from.day, 28)))
|
||||||
|
period_b_to = parse_date_arg("period_b_to", date(previous_year, period_a_to.month, min(period_a_to.day, 28)))
|
||||||
|
if period_a_from > period_a_to:
|
||||||
|
period_a_from, period_a_to = period_a_to, period_a_from
|
||||||
|
if period_b_from > period_b_to:
|
||||||
|
period_b_from, period_b_to = period_b_to, period_b_from
|
||||||
|
|
||||||
|
ranges = [
|
||||||
|
("Okres A", period_a_from, period_a_to),
|
||||||
|
("Okres B", period_b_from, period_b_to),
|
||||||
|
]
|
||||||
|
all_rows = base_query.filter(
|
||||||
|
or_(
|
||||||
|
OrlenPrice.effective_date.between(period_a_from, period_a_to),
|
||||||
|
OrlenPrice.effective_date.between(period_b_from, period_b_to),
|
||||||
|
)
|
||||||
|
).order_by(OrlenPrice.effective_date).all()
|
||||||
|
for label, start, end in ranges:
|
||||||
|
period_rows = [row for row in all_rows if start <= row.effective_date <= end]
|
||||||
|
chart[label] = [{"date": f"Dzień {(row.effective_date - start).days + 1:03d}", "value": priced_value(row)} for row in period_rows]
|
||||||
|
comparison_summary.append(summary(f"{label}: {start.isoformat()} – {end.isoformat()}", period_rows))
|
||||||
|
rows = all_rows
|
||||||
|
else:
|
||||||
|
query = OrlenPrice.query.filter(OrlenPrice.fuel_type.in_(selected_fuels), extract("year", OrlenPrice.effective_date) == selected_year)
|
||||||
|
rows = query.order_by(OrlenPrice.effective_date, OrlenPrice.fuel_type).all()
|
||||||
|
for fuel in selected_fuels:
|
||||||
|
fuel_rows = [row for row in rows if row.fuel_type == fuel and (fuel != "LPG" or row.region in selected_regions)]
|
||||||
|
if fuel == "LPG":
|
||||||
|
for region in selected_regions:
|
||||||
|
chart[f"LPG · {region}"] = [{"date": row.effective_date.isoformat(), "value": priced_value(row)} for row in fuel_rows if row.region == region]
|
||||||
|
else:
|
||||||
|
chart[fuel] = [{"date": row.effective_date.isoformat(), "value": priced_value(row)} for row in fuel_rows]
|
||||||
|
period_a_from = period_a_to = period_b_from = period_b_to = None
|
||||||
|
|
||||||
|
summary_difference = None
|
||||||
|
if len(comparison_summary) == 2 and comparison_summary[0]["average"] is not None and comparison_summary[1]["average"] is not None:
|
||||||
|
first_average = comparison_summary[0]["average"]
|
||||||
|
second_average = comparison_summary[1]["average"]
|
||||||
|
summary_difference = {
|
||||||
|
"value": round(first_average - second_average, 4),
|
||||||
|
"percent": round((first_average - second_average) / second_average * 100, 2) if second_average else None,
|
||||||
|
}
|
||||||
|
|
||||||
|
return render_template(
|
||||||
|
"orlen.html", rows=rows, chart=chart, mode=mode,
|
||||||
|
selected_fuels=selected_fuels, selected_year=selected_year,
|
||||||
|
compare_year=compare_year, compare_fuel=compare_fuel,
|
||||||
|
comparison_type=comparison_type, comparison_summary=comparison_summary,
|
||||||
|
summary_difference=summary_difference, compare_region=compare_region,
|
||||||
|
period_a_from=period_a_from, period_a_to=period_a_to,
|
||||||
|
period_b_from=period_b_from, period_b_to=period_b_to,
|
||||||
|
settings=settings, fuel_types=FUEL_TYPES, include_vat=include_vat,
|
||||||
|
regions=POLISH_REGIONS, selected_regions=selected_regions,
|
||||||
|
available_years=available_years,
|
||||||
|
)
|
||||||
|
|
||||||
@main.post("/orlen/sync")
|
@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
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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'}}}},
|
||||||
|
|||||||
@@ -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,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
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -16,6 +16,12 @@
|
|||||||
[data-bs-theme="dark"] .table thead th{color:#adb5bd}
|
[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"] .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"] .bg-body-tertiary{background-color:#181b1f!important}
|
||||||
[data-bs-theme="dark"] .dropdown-menu{background:#202327;border-color:#3a3f45;box-shadow:0 .75rem 2rem rgba(0,0,0,.35)}
|
[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{color:#e9ecef}
|
||||||
[data-bs-theme="dark"] .dropdown-item:hover,[data-bs-theme="dark"] .dropdown-item:focus{background:#2b3035;color:#fff}
|
[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}
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
: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)}
|
: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;display:flex;flex-direction:column;background:var(--bs-tertiary-bg)}
|
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)}
|
.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)}
|
.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-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-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}
|
.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}
|
.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{max-width:min(420px,calc(100vw - 2rem))}.app-alert{box-shadow:var(--app-shadow);border:0}
|
#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:radial-gradient(circle at 20% 10%,color-mix(in srgb,var(--bs-primary) 14%,transparent),transparent 34%),radial-gradient(circle at 85% 85%,color-mix(in srgb,var(--bs-warning) 10%,transparent),transparent 30%),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:0 1.25rem 3rem rgba(15,23,42,.13)}
|
.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. */
|
/* Geometria interfejsu jest wspólna dla wszystkich motywów. Motyw zmienia wyłącznie kolory. */
|
||||||
.app-navbar{min-height:4.25rem}
|
.app-navbar{min-height:4.25rem}
|
||||||
@@ -17,11 +17,15 @@ html,body{min-height:100%}body{min-height:100vh;display:flex;flex-direction:colu
|
|||||||
.app-brand{font-family:inherit;font-size:1.4rem!important;font-weight:800!important;line-height:1!important;letter-spacing:-.04em!important}
|
.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}
|
.app-navbar .nav-link{font-size:1rem;line-height:1.5}
|
||||||
|
|
||||||
[data-bs-theme="dark"]{--app-shadow:0 .45rem 1.6rem rgba(0,0,0,.28);--app-shadow-hover:0 .8rem 2.2rem rgba(0,0,0,.38)}
|
[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"] body{background:#17191c;color:#f1f3f5}
|
||||||
[data-bs-theme="dark"] .app-navbar{background:rgba(24,26,29,.94);border-bottom-color:#34383d;box-shadow:0 .2rem 1rem rgba(0,0,0,.32)}
|
[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"] .app-footer{background:#1b1d20;border-top-color:#34383d}
|
||||||
[data-bs-theme="dark"] .brand-fuel{color:#f8f9fa}
|
[data-bs-theme="dark"] .brand-fuel{color:#f8f9fa}
|
||||||
[data-bs-theme="dark"] .auth-page{background:radial-gradient(circle at 20% 10%,rgba(55,139,230,.16),transparent 36%),radial-gradient(circle at 85% 85%,rgba(255,193,7,.08),transparent 32%),#141619}
|
[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: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}}
|
@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 @@
|
|||||||
(()=>{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 @@
|
|||||||
(()=>{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
@@ -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()}});
|
||||||
|
};
|
||||||
|
})();
|
||||||
|
|||||||
@@ -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)}};
|
||||||
|
|||||||
@@ -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 @@
|
|||||||
FuelTrack.initPage=()=>{FuelTrack.bindAjaxForms();FuelTrack.bindFuelPreview();FuelTrack.bindOrlenSync();FuelTrack.bindUserModal();FuelTrack.bindAuth();FuelTrack.bindStationLiveSearch();FuelTrack.bindStationFavoriteToggles();FuelTrack.bindStationInlineCompany();FuelTrack.bindLpgRegionPicker();FuelTrack.bindFavoriteStations();FuelTrack.bindStationProposalSelect();FuelTrack.bindStationPoints()};window.addEventListener('popstate',()=>FuelTrack.replaceMain(location.href));document.addEventListener('DOMContentLoaded',()=>FuelTrack.initPage());
|
FuelTrack.initPage=()=>{FuelTrack.bindAjaxForms();FuelTrack.bindFuelPreview();FuelTrack.bindOrlenSync();FuelTrack.bindUserModal();FuelTrack.bindFuelEntryModal();FuelTrack.bindAuth();FuelTrack.bindStationLiveSearch();FuelTrack.bindStationFavoriteToggles();FuelTrack.bindStationInlineCompany();FuelTrack.bindLpgRegionPicker();FuelTrack.bindFavoriteStations();FuelTrack.bindStationProposalSelect();FuelTrack.bindStationPoints()};window.addEventListener('popstate',()=>FuelTrack.replaceMain(location.href));document.addEventListener('DOMContentLoaded',()=>FuelTrack.initPage());
|
||||||
|
|||||||
@@ -1 +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,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>
|
||||||
|
|||||||
@@ -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,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 %}
|
||||||
|
|||||||
@@ -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 %}
|
||||||
@@ -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 %}
|
||||||
+14
-5
@@ -2,7 +2,9 @@
|
|||||||
<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">
|
<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 }}">
|
||||||
@@ -31,16 +33,22 @@
|
|||||||
<a class="nav-link {% if request.endpoint and request.endpoint.startswith('main.admin') %}active{% endif %}" href="{{ url_for('main.admin_companies') }}">Administracja</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 %}
|
{% endif %}
|
||||||
<span class="navbar-divider d-none d-lg-block"></span>
|
<span class="navbar-divider d-none d-lg-block"></span>
|
||||||
<a class="btn btn-outline-danger btn-sm ms-lg-2" href="/api/auth/logout" data-api-logout>Wyloguj</a>
|
<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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<main class="container app-main {% if not current_user.is_authenticated %}auth-main{% endif %}">
|
{% with msgs=get_flashed_messages(with_categories=true) %}
|
||||||
{% 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 %}
|
{% 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 %}
|
||||||
{% endwith %}
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% endwith %}
|
||||||
|
<main class="container app-main {% if not current_user.is_authenticated %}auth-main{% endif %}">
|
||||||
{% block content %}{% endblock %}
|
{% block content %}{% endblock %}
|
||||||
</main>
|
</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 %}
|
{% 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 %}
|
||||||
@@ -55,6 +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/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>
|
||||||
|
|||||||
@@ -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 %}
|
||||||
|
|||||||
@@ -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 %}
|
||||||
|
|||||||
@@ -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
@@ -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()
|
||||||
|
|||||||
@@ -1,31 +1,47 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
cd "$ROOT_DIR"
|
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}"
|
||||||
|
|
||||||
if [[ -f .env ]]; then
|
# 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
|
set -a
|
||||||
# shellcheck disable=SC1091
|
# shellcheck disable=SC1090
|
||||||
source .env
|
source "$ENV_FILE"
|
||||||
set +a
|
set +a
|
||||||
fi
|
fi
|
||||||
|
|
||||||
mkdir -p instance
|
mkdir -p "$INSTANCE_DIR"
|
||||||
|
mkdir -p "$(dirname -- "$DB_FILE")"
|
||||||
|
|
||||||
export FLASK_ENV="${FLASK_ENV:-development}"
|
export FLASK_ENV="${FLASK_ENV:-development}"
|
||||||
export DATABASE_URL="${DATABASE_URL:-sqlite:///$ROOT_DIR/instance/fueltrack.db}"
|
|
||||||
export APP_HOST="${APP_HOST:-0.0.0.0}"
|
export APP_HOST="${APP_HOST:-0.0.0.0}"
|
||||||
export APP_PORT="${APP_PORT:-8000}"
|
export APP_PORT="${APP_PORT:-8000}"
|
||||||
export GUNICORN_WORKERS="${GUNICORN_WORKERS:-1}"
|
export GUNICORN_WORKERS="${GUNICORN_WORKERS:-1}"
|
||||||
export GUNICORN_THREADS="${GUNICORN_THREADS:-8}"
|
export GUNICORN_THREADS="${GUNICORN_THREADS:-8}"
|
||||||
export GUNICORN_TIMEOUT="${GUNICORN_TIMEOUT:-0}"
|
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 "FuelTrack DEV"
|
||||||
echo "Adres: http://localhost:${APP_PORT}"
|
echo "Katalog aplikacji: $APP_DIR"
|
||||||
echo "Baza: ${DATABASE_URL}"
|
echo "Adres: http://localhost:${APP_PORT}"
|
||||||
|
echo "Baza: $DATABASE_URL"
|
||||||
|
|
||||||
exec gunicorn \
|
exec gunicorn \
|
||||||
|
--chdir "$APP_DIR" \
|
||||||
--reload \
|
--reload \
|
||||||
--access-logfile - \
|
--access-logfile - \
|
||||||
--error-logfile - \
|
--error-logfile - \
|
||||||
@@ -34,4 +50,4 @@ exec gunicorn \
|
|||||||
--threads "$GUNICORN_THREADS" \
|
--threads "$GUNICORN_THREADS" \
|
||||||
--timeout "$GUNICORN_TIMEOUT" \
|
--timeout "$GUNICORN_TIMEOUT" \
|
||||||
--bind "$APP_HOST:$APP_PORT" \
|
--bind "$APP_HOST:$APP_PORT" \
|
||||||
wsgi:app
|
"$GUNICORN_APP"
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user