first commit
This commit is contained in:
@@ -0,0 +1,99 @@
|
||||
from flask import Flask
|
||||
import click
|
||||
from sqlalchemy import text
|
||||
from .extensions import db, login_manager, socketio
|
||||
from .models import User, CompanySettings, AppSetting
|
||||
from .config import Config
|
||||
|
||||
THEMES = {name: {} for name in ("bootstrap", "flatly", "darkly", "pulse")}
|
||||
|
||||
def _themes(app):
|
||||
return {
|
||||
"bootstrap": {"label":"Bootstrap", "url":app.config["THEME_BOOTSTRAP_URL"], "mode":"light"},
|
||||
"flatly": {"label":"Flatly", "url":app.config["THEME_FLATLY_URL"], "mode":"light"},
|
||||
"darkly": {"label":"Darkly", "url":app.config["THEME_DARKLY_URL"], "mode":"dark"},
|
||||
"pulse": {"label":"Pulse", "url":app.config["THEME_PULSE_URL"], "mode":"light"},
|
||||
}
|
||||
|
||||
|
||||
def create_app(test_config=None):
|
||||
app = Flask(__name__)
|
||||
app.config.from_object(Config)
|
||||
if test_config: app.config.update(test_config)
|
||||
db.init_app(app); login_manager.init_app(app); socketio.init_app(app)
|
||||
from .auth import auth; from .main import main
|
||||
from .api import api
|
||||
from .openapi import docs
|
||||
app.register_blueprint(auth); app.register_blueprint(main); app.register_blueprint(api); app.register_blueprint(docs)
|
||||
|
||||
@login_manager.user_loader
|
||||
def load_user(user_id): return db.session.get(User, int(user_id))
|
||||
|
||||
@app.context_processor
|
||||
def inject_theme():
|
||||
themes = _themes(app)
|
||||
record = db.session.get(AppSetting, "theme")
|
||||
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"],
|
||||
"choices_js": app.config["CHOICES_JS_URL"], "chart_js": app.config["CHART_JS_URL"], "socket_io_js": app.config["SOCKET_IO_JS_URL"]}}
|
||||
|
||||
@app.get("/health")
|
||||
def health(): return {"status":"ok"}
|
||||
|
||||
@app.cli.command("reset-admin-password")
|
||||
@click.option("--email", required=True, help="Adres e-mail konta administratora.")
|
||||
@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.")
|
||||
def reset_admin_password(email, password):
|
||||
"""Resetuje hasło istniejącego administratora."""
|
||||
normalized_email = email.strip().lower()
|
||||
user = User.query.filter_by(email=normalized_email).first()
|
||||
if not user:
|
||||
raise click.ClickException("Nie znaleziono użytkownika o podanym adresie e-mail.")
|
||||
if user.role != "admin":
|
||||
raise click.ClickException("Wskazany użytkownik nie ma roli administratora.")
|
||||
if len(password) < 8:
|
||||
raise click.ClickException("Hasło musi mieć co najmniej 8 znaków.")
|
||||
user.set_password(password)
|
||||
user.active = True
|
||||
db.session.commit()
|
||||
click.echo(f"Zresetowano hasło administratora: {normalized_email}")
|
||||
|
||||
with app.app_context():
|
||||
db.create_all()
|
||||
if db.engine.dialect.name == "sqlite":
|
||||
def add_column(table, column, ddl, index_sql=None):
|
||||
columns = {row[1] for row in db.session.execute(text(f"PRAGMA table_info({table})")).fetchall()}
|
||||
if column not in columns:
|
||||
db.session.execute(text(f"ALTER TABLE {table} ADD COLUMN {ddl}"))
|
||||
if index_sql:
|
||||
db.session.execute(text(index_sql))
|
||||
add_column("fuel_entry", "station_company_id", "station_company_id INTEGER REFERENCES fuel_station_company(id)", "CREATE INDEX IF NOT EXISTS ix_fuel_entry_station_company_id ON fuel_entry (station_company_id)")
|
||||
add_column("fuel_entry", "fuel_card_id", "fuel_card_id INTEGER REFERENCES fuel_card(id)", "CREATE INDEX IF NOT EXISTS ix_fuel_entry_fuel_card_id ON fuel_entry (fuel_card_id)")
|
||||
add_column("vehicle", "fuel_card_id", "fuel_card_id INTEGER REFERENCES fuel_card(id)", "CREATE INDEX IF NOT EXISTS ix_vehicle_fuel_card_id ON vehicle (fuel_card_id)")
|
||||
add_column("user", "fuel_card_id", "fuel_card_id INTEGER REFERENCES fuel_card(id)", "CREATE INDEX IF NOT EXISTS ix_user_fuel_card_id ON user (fuel_card_id)")
|
||||
add_column("user", "company_id", "company_id INTEGER REFERENCES company_settings(id)", "CREATE INDEX IF NOT EXISTS ix_user_company_id ON user (company_id)")
|
||||
add_column("vehicle", "company_id", "company_id INTEGER REFERENCES company_settings(id)", "CREATE INDEX IF NOT EXISTS ix_vehicle_company_id ON vehicle (company_id)")
|
||||
add_column("fuel_card", "company_id", "company_id INTEGER REFERENCES company_settings(id)", "CREATE INDEX IF NOT EXISTS ix_fuel_card_company_id ON fuel_card (company_id)")
|
||||
add_column("company_settings", "active", "active BOOLEAN NOT NULL DEFAULT 1")
|
||||
add_column("fuel_station_company", "brand_name", "brand_name VARCHAR(120) NOT NULL DEFAULT ''", "CREATE INDEX IF NOT EXISTS ix_fuel_station_company_brand_name ON fuel_station_company (brand_name)")
|
||||
db.session.commit()
|
||||
if not CompanySettings.query.first(): db.session.add(CompanySettings())
|
||||
if not db.session.get(AppSetting, "theme"): db.session.add(AppSetting(key="theme", value="bootstrap"))
|
||||
if not db.session.get(AppSetting, "invoice_split_enabled"): db.session.add(AppSetting(key="invoice_split_enabled", value="1"))
|
||||
if not User.query.first():
|
||||
u=User(email="admin@example.com", name="Administrator", role="admin"); u.set_password("admin123!"); db.session.add(u)
|
||||
db.session.commit()
|
||||
from .models import FuelStationCompany
|
||||
from .integrations.ure import infer_brand
|
||||
for station in FuelStationCompany.query.filter(db.or_(FuelStationCompany.brand_name.is_(None), FuelStationCompany.brand_name == "")).all():
|
||||
station.brand_name = infer_brand(station.company_name)
|
||||
db.session.commit()
|
||||
default_company=CompanySettings.query.order_by(CompanySettings.id).first()
|
||||
if default_company:
|
||||
User.query.filter(User.company_id.is_(None)).update({User.company_id:default_company.id})
|
||||
db.session.execute(text("UPDATE vehicle SET company_id = (SELECT company_id FROM user WHERE user.id = vehicle.owner_id) WHERE company_id IS NULL"))
|
||||
db.session.execute(text("UPDATE fuel_card SET company_id = :cid WHERE company_id IS NULL"), {"cid":default_company.id})
|
||||
db.session.commit()
|
||||
return app
|
||||
+414
@@ -0,0 +1,414 @@
|
||||
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
|
||||
from flask_login import current_user, login_required, login_user, logout_user
|
||||
from sqlalchemy import extract
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from .extensions import db, socketio
|
||||
from .models import User, Vehicle, FuelEntry, CompanySettings, AppSetting, OrlenPrice, FuelCardPolicy, FuelStationCompany, FuelStationPoint, FuelCard, FuelCardStationRule
|
||||
from .services import calculate_costs, fetch_orlen_price, fetch_orlen_range, invoice_period, fetch_ure_stations, aggregate_ure_companies, POLISH_REGIONS
|
||||
from .station_catalog import sync_station_catalog
|
||||
from . import THEMES
|
||||
|
||||
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/logout')
|
||||
@login_required
|
||||
def logout(): logout_user(); return response({'redirect':url_for('auth.login')},'Wylogowano')
|
||||
|
||||
@api.get('/me')
|
||||
@login_required
|
||||
def me(): return response(user_dict(current_user))
|
||||
|
||||
@api.get('/vehicles')
|
||||
@login_required
|
||||
def get_vehicles(): return response([vehicle_dict(v) for v in accessible_vehicles()])
|
||||
|
||||
@api.post('/vehicles')
|
||||
@login_required
|
||||
def create_vehicle():
|
||||
d=payload()
|
||||
try:
|
||||
card_id=int(d['fuel_card_id']) if d.get('fuel_card_id') else None
|
||||
owner_id=int(d.get('owner_id') or current_user.id);owner=db.session.get(User,owner_id)
|
||||
if not owner: return fail('Nie znaleziono właściciela',404)
|
||||
if current_user.role not in ('boss','admin') and owner.id!=current_user.id:return fail('Brak uprawnień',403)
|
||||
if current_user.role=='boss' and owner.company_id!=current_user.company_id:return fail('Użytkownik jest w innej firmie',403)
|
||||
v=Vehicle(name=d['name'].strip(),make=d['make'].strip(),model=d['model'].strip(),registration=d['registration'].strip().upper(),fuel_type=d['fuel_type'],owner_id=owner.id,company_id=owner.company_id,has_fuel_card=bool(card_id),fuel_card_id=card_id,current_odometer=int(d.get('current_odometer') or 0))
|
||||
db.session.add(v); db.session.commit(); socketio.emit('app_changed',{'resource':'vehicle','id':v.id}); return response(vehicle_dict(v),'Dodano pojazd',201)
|
||||
except (KeyError,ValueError,IntegrityError) as exc: db.session.rollback(); return fail(str(getattr(exc,'orig',exc)),400)
|
||||
|
||||
@api.put('/vehicles/<int:vehicle_id>/fuel-card')
|
||||
@login_required
|
||||
def assign_vehicle_fuel_card(vehicle_id):
|
||||
v=Vehicle.query.get_or_404(vehicle_id)
|
||||
if current_user.role not in ('boss','admin') and v.owner_id!=current_user.id:return fail('Brak uprawnień',403)
|
||||
d=payload();card_id=int(d['fuel_card_id']) if d.get('fuel_card_id') else None
|
||||
card=db.session.get(FuelCard,card_id) if card_id else None
|
||||
if card_id and not card:return fail('Nie znaleziono karty paliwowej',404)
|
||||
if card and v.company_id and card.company_id!=v.company_id:return fail('Karta należy do innej firmy',400)
|
||||
v.fuel_card_id=card_id;v.has_fuel_card=bool(card_id);db.session.commit();socketio.emit('app_changed',{'resource':'vehicle','id':v.id});return response(vehicle_dict(v),'Zapisano kartę pojazdu')
|
||||
|
||||
@api.post('/vehicles/<int:vehicle_id>/shares')
|
||||
@login_required
|
||||
def share_vehicle(vehicle_id):
|
||||
v=Vehicle.query.get_or_404(vehicle_id)
|
||||
if current_user.role!='admin' and v.owner_id!=current_user.id: return fail('Brak uprawnień',403)
|
||||
d=payload(); u=User.query.filter_by(email=(d.get('email') or '').strip().lower()).first()
|
||||
if not u:return fail('Nie znaleziono użytkownika',404)
|
||||
if u not in v.drivers:v.drivers.append(u);db.session.commit()
|
||||
socketio.emit('app_changed',{'resource':'vehicle_share','id':v.id});return response(vehicle_dict(v),'Udostępniono pojazd')
|
||||
|
||||
@api.delete('/vehicles/<int:vehicle_id>/shares/<int:user_id>')
|
||||
@login_required
|
||||
def unshare_vehicle(vehicle_id,user_id):
|
||||
v=Vehicle.query.get_or_404(vehicle_id)
|
||||
if current_user.role!='admin' and v.owner_id!=current_user.id:return fail('Brak uprawnień',403)
|
||||
u=User.query.get_or_404(user_id)
|
||||
if u in v.drivers:v.drivers.remove(u);db.session.commit()
|
||||
socketio.emit('app_changed',{'resource':'vehicle_share','id':v.id});return response(vehicle_dict(v),'Odebrano dostęp')
|
||||
|
||||
@api.put('/vehicles/<int:vehicle_id>/card-policy')
|
||||
@login_required
|
||||
def card_policy(vehicle_id):
|
||||
v=Vehicle.query.get_or_404(vehicle_id)
|
||||
if current_user.role!='admin' and v.owner_id!=current_user.id:return fail('Brak uprawnień',403)
|
||||
d=payload();p=v.fuel_card_policy or FuelCardPolicy(vehicle=v)
|
||||
p.use_orlen_last_price=False;p.discount_percent=Decimal(d.get('discount_percent') or 0);p.surcharge_per_liter=Decimal(d.get('surcharge_per_liter') or 0);p.description=(d.get('description') or '').strip() or None
|
||||
db.session.add(p);db.session.commit();socketio.emit('app_changed',{'resource':'card_policy','id':v.id});return response(message='Zapisano zasady rozliczeń')
|
||||
|
||||
@api.post('/fuel-entries')
|
||||
@login_required
|
||||
def create_fuel_entry():
|
||||
d=payload(); vehicles=accessible_vehicles()
|
||||
try: vehicle=next((v for v in vehicles if v.id==int(d['vehicle_id'])),None)
|
||||
except Exception: vehicle=None
|
||||
if not vehicle:return fail('Brak dostępu do pojazdu',403)
|
||||
try:
|
||||
odo=int(d['odometer']); fueled_at=datetime.fromisoformat(d['fueled_at']); station_company=db.session.get(FuelStationCompany,int(d['station_company_id'])) if d.get('station_company_id') else None
|
||||
settings=vehicle.company or current_user.company or CompanySettings.query.first()
|
||||
if station_company and settings.allowed_stations and station_company not in settings.allowed_stations:
|
||||
return fail('Ta stacja nie jest dozwolona przez politykę firmy',403)
|
||||
wholesale=source=None; region=(station_company.selected_region if station_company else None) or settings.region
|
||||
try:
|
||||
p=fetch_orlen_price(d['fuel_type'],fueled_at.date(),region);wholesale=p['price_per_liter'];source=p['source']
|
||||
except Exception: pass
|
||||
card_id=int(d['fuel_card_id']) if d.get('fuel_card_id') else (vehicle.fuel_card_id or current_user.fuel_card_id)
|
||||
card=db.session.get(FuelCard,card_id) if card_id else None
|
||||
used_card=boolv(d,'used_fuel_card') and bool(card)
|
||||
if used_card and station_company:
|
||||
rule=FuelCardStationRule.query.filter_by(fuel_card_id=card.id,station_company_id=station_company.id).first()
|
||||
if rule and not rule.allowed:return fail('Ta stacja jest zablokowana dla wybranej karty paliwowej',403)
|
||||
e=FuelEntry(vehicle_id=vehicle.id,user_id=current_user.id,fueled_at=fueled_at,liters=Decimal(d['liters']),price_per_liter=Decimal(d['price_per_liter']),fuel_type=d['fuel_type'],odometer=odo,station=station_company.company_name if station_company else d.get('station'),station_company_id=station_company.id if station_company else None,fuel_card_id=card.id if used_card else None,invoice_number=d.get('invoice_number'),used_fuel_card=used_card,wholesale_price=wholesale,wholesale_source=source)
|
||||
vehicle.current_odometer=odo;db.session.add(e);db.session.commit();socketio.emit('fuel_added',{'vehicle':vehicle.name,'gross':round(e.gross,2)});return response({'id':e.id,'redirect':url_for('main.dashboard')},'Zapisano tankowanie',201)
|
||||
except Exception as exc:db.session.rollback();return fail(str(exc),400)
|
||||
|
||||
@api.get('/orlen/prices')
|
||||
@login_required
|
||||
def orlen_prices():
|
||||
year=request.args.get('year',type=int) or date.today().year; fuels=[x.upper() for x in request.args.getlist('fuel') if x.upper() in FUEL_TYPES] or ['PB95']; regions=[x.lower() for x in request.args.getlist('region') if x.lower() in POLISH_REGIONS]
|
||||
rows=OrlenPrice.query.filter(OrlenPrice.fuel_type.in_(fuels),extract('year',OrlenPrice.effective_date)==year).order_by(OrlenPrice.effective_date).all()
|
||||
return response([{'fuel_type':r.fuel_type,'date':r.effective_date.isoformat(),'price_per_liter':float(r.price_per_liter),'region':r.region,'source':r.source} for r in rows if r.fuel_type!='LPG' or not regions or r.region in regions])
|
||||
|
||||
@api.post('/orlen/sync')
|
||||
@role_required('boss','admin')
|
||||
def orlen_sync():
|
||||
d=payload(); fuels=d.get('fuels',[]); fuels=[fuels] if isinstance(fuels,str) else fuels; fuels=[f.upper() for f in fuels if f.upper() in FUEL_TYPES]; year=int(d.get('year',date.today().year))
|
||||
regions=d.get('regions',[]); regions=[regions] if isinstance(regions,str) else regions; regions=[str(r).strip().lower() for r in regions if str(r).strip().lower() in POLISH_REGIONS]
|
||||
if not fuels:return fail('Wybierz co najmniej jedno paliwo')
|
||||
settings=CompanySettings.query.first(); result={}; total=0
|
||||
try:
|
||||
for fuel in fuels:
|
||||
added=updated=0
|
||||
for row in fetch_orlen_range(fuel,date(year,1,1),min(date(year,12,31),date.today()),'all' if fuel=='LPG' else settings.region):
|
||||
if fuel=='LPG' and regions and row.get('region') not in regions: continue
|
||||
x=OrlenPrice.query.filter_by(fuel_type=row['fuel_type'],effective_date=row['effective_date'],region=row['region']).first()
|
||||
if x:
|
||||
for k,v in row.items():setattr(x,k,v)
|
||||
x.fetched_at=datetime.utcnow();updated+=1
|
||||
else:db.session.add(OrlenPrice(**row));added+=1
|
||||
result[fuel]={'added':added,'updated':updated};total+=added+updated
|
||||
db.session.commit();socketio.emit('app_changed',{'resource':'orlen'});return response(result,f'Przetworzono {total} rekordów')
|
||||
except Exception as exc:db.session.rollback();return fail(str(exc),502)
|
||||
|
||||
@api.get('/orlen/preview')
|
||||
@login_required
|
||||
def orlen_preview():
|
||||
try:return response(fetch_orlen_price(request.args['fuel_type'],datetime.fromisoformat(request.args['date']).date(),request.args.get('region') or CompanySettings.query.first().region))
|
||||
except Exception as exc:return fail(str(exc),502)
|
||||
|
||||
@api.get('/users')
|
||||
@role_required('admin')
|
||||
def users():
|
||||
page=request.args.get('page',1,type=int);per=min(request.args.get('per_page',25,type=int),100);q=request.args.get('q','').strip();query=User.query
|
||||
if q: query=query.filter(db.or_(User.name.ilike(f'%{q}%'),User.email.ilike(f'%{q}%')))
|
||||
p=query.order_by(User.name).paginate(page=page,per_page=per,error_out=False);return response({'items':[user_dict(x) for x in p.items],'page':p.page,'pages':p.pages,'total':p.total})
|
||||
|
||||
@api.post('/users')
|
||||
@role_required('boss','admin')
|
||||
def create_user():
|
||||
d=payload()
|
||||
try:
|
||||
if len(d.get('password',''))<8:raise ValueError('Hasło musi mieć co najmniej 8 znaków')
|
||||
company_id=int(d.get('company_id') or current_user.company_id or CompanySettings.query.first().id)
|
||||
if current_user.role=='boss' and company_id!=current_user.company_id:return fail('Brak uprawnień',403)
|
||||
u=User(name=d['user_name'].strip(),email=d['email'].strip().lower(),role=d.get('role','user'),company_id=company_id);u.set_password(d['password']);db.session.add(u);db.session.commit();return response(user_dict(u),'Dodano użytkownika',201)
|
||||
except Exception as exc:db.session.rollback();return fail(str(getattr(exc,'orig',exc)))
|
||||
|
||||
@api.put('/users/<int:user_id>')
|
||||
@role_required('admin')
|
||||
def update_user(user_id):
|
||||
u=User.query.get_or_404(user_id);d=payload()
|
||||
try:
|
||||
u.name=d['name'].strip();u.email=d['email'].strip().lower();u.role=d['role'];u.active=boolv(d,'active');u.company_id=int(d.get('company_id') or u.company_id or CompanySettings.query.first().id);u.fuel_card_id=int(d['fuel_card_id']) if d.get('fuel_card_id') else None;pw=d.get('password','')
|
||||
if pw:
|
||||
if len(pw)<8:raise ValueError('Nowe hasło musi mieć co najmniej 8 znaków')
|
||||
u.set_password(pw)
|
||||
db.session.commit();return response(user_dict(u),'Zaktualizowano użytkownika')
|
||||
except Exception as exc:db.session.rollback();return fail(str(getattr(exc,'orig',exc)))
|
||||
|
||||
@api.delete('/users/<int:user_id>')
|
||||
@role_required('admin')
|
||||
def delete_user(user_id):
|
||||
user = User.query.get_or_404(user_id)
|
||||
if user.id == current_user.id:
|
||||
return fail('Nie możesz usunąć własnego konta administratora', 400)
|
||||
if user.role == 'admin':
|
||||
remaining_active_admins = User.query.filter(
|
||||
User.role == 'admin', User.active.is_(True), User.id != user.id
|
||||
).count()
|
||||
if remaining_active_admins < 1:
|
||||
return fail('Nie można usunąć ostatniego aktywnego administratora', 409)
|
||||
if user.owned_vehicles:
|
||||
return fail('Najpierw przypisz pojazdy tego użytkownika innemu właścicielowi', 409)
|
||||
if FuelEntry.query.filter_by(user_id=user.id).first():
|
||||
return fail('Użytkownik ma historię tankowań i nie może zostać usunięty. Możesz go dezaktywować.', 409)
|
||||
user.shared_vehicles.clear()
|
||||
user.favorite_stations.clear()
|
||||
db.session.delete(user)
|
||||
db.session.commit()
|
||||
socketio.emit('app_changed', {'resource':'users','deleted_id':user_id})
|
||||
return response(message='Usunięto użytkownika')
|
||||
|
||||
@api.get('/settings')
|
||||
@role_required('boss','admin')
|
||||
def get_settings():
|
||||
s=CompanySettings.query.first();return response({'name':s.name,'entity_type':s.entity_type,'vat_rate':float(s.vat_rate),'vat_deduction_percent':float(s.vat_deduction_percent),'region':s.region,'invoice_split_day':s.invoice_split_day,'invoice_split_enabled':(db.session.get(AppSetting,'invoice_split_enabled').value=='1')})
|
||||
|
||||
@api.put('/settings')
|
||||
@role_required('boss','admin')
|
||||
def update_settings():
|
||||
d=payload();s=CompanySettings.query.first()
|
||||
try:
|
||||
s.name=d['name'];s.entity_type=d['entity_type'];s.vat_rate=Decimal(d['vat_rate']);s.vat_deduction_percent=Decimal(d['vat_deduction_percent']);s.region=d['region'].strip().lower();s.invoice_split_day=int(d.get('invoice_split_day') or 15)
|
||||
db.session.merge(AppSetting(key='invoice_split_enabled',value='1' if boolv(d,'invoice_split_enabled') else '0'));db.session.commit();socketio.emit('app_changed',{'resource':'settings'});return response(message='Zapisano ustawienia')
|
||||
except Exception as exc:db.session.rollback();return fail(str(exc))
|
||||
|
||||
|
||||
@api.get('/app-settings')
|
||||
@role_required('boss','admin')
|
||||
def get_app_settings():
|
||||
return response({'theme':(db.session.get(AppSetting,'theme') or AppSetting(value='bootstrap')).value})
|
||||
|
||||
@api.put('/app-settings')
|
||||
@role_required('boss','admin')
|
||||
def update_app_settings():
|
||||
d=payload();theme=d.get('theme','bootstrap')
|
||||
if theme not in THEMES:return fail('Nieprawidłowy motyw')
|
||||
db.session.merge(AppSetting(key='theme',value=theme));db.session.commit();socketio.emit('app_changed',{'resource':'app_settings'});return response(message='Zapisano ustawienia aplikacji')
|
||||
|
||||
@api.get('/fuel-cards')
|
||||
@login_required
|
||||
def get_fuel_cards():
|
||||
rows=FuelCard.query.order_by(FuelCard.name).all()
|
||||
return response([{'id':x.id,'name':x.name,'provider':x.provider,'active':x.active,'description':x.description} for x in rows])
|
||||
|
||||
@api.post('/fuel-cards')
|
||||
@role_required('boss','admin')
|
||||
def create_fuel_card():
|
||||
d=payload()
|
||||
try:
|
||||
company_id=int(d.get('company_id') or current_user.company_id or CompanySettings.query.first().id)
|
||||
if current_user.role=='boss' and company_id!=current_user.company_id:return fail('Brak uprawnień',403)
|
||||
card=FuelCard(name=d['name'].strip(),provider=d['provider'].strip(),description=(d.get('description') or '').strip() or None,active=True,company_id=company_id)
|
||||
db.session.add(card);db.session.commit();socketio.emit('app_changed',{'resource':'fuel_cards'});return response({'id':card.id},'Dodano kartę paliwową',201)
|
||||
except Exception as exc:db.session.rollback();return fail(str(getattr(exc,'orig',exc)))
|
||||
|
||||
@api.put('/fuel-cards/<int:card_id>')
|
||||
@role_required('boss','admin')
|
||||
def update_fuel_card(card_id):
|
||||
card=FuelCard.query.get_or_404(card_id);d=payload();card.name=d.get('name',card.name).strip();card.provider=d.get('provider',card.provider).strip();card.description=(d.get('description') or '').strip() or None;card.active=boolv(d,'active');db.session.commit();return response(message='Zapisano kartę')
|
||||
|
||||
@api.put('/fuel-cards/<int:card_id>/stations/<int:station_id>')
|
||||
@role_required('boss','admin')
|
||||
def update_card_station_rule(card_id,station_id):
|
||||
FuelCard.query.get_or_404(card_id);FuelStationCompany.query.get_or_404(station_id);d=payload()
|
||||
rule=FuelCardStationRule.query.filter_by(fuel_card_id=card_id,station_company_id=station_id).first() or FuelCardStationRule(fuel_card_id=card_id,station_company_id=station_id)
|
||||
rule.allowed=boolv(d,'allowed');rule.use_orlen_last_price=boolv(d,'use_orlen_last_price');rule.discount_net_percent=Decimal(d.get('discount_net_percent') or 0);rule.surcharge_net_per_liter=Decimal(d.get('surcharge_net_per_liter') or 0)
|
||||
db.session.add(rule);db.session.commit();socketio.emit('app_changed',{'resource':'card_station_rule'});return response(message='Zapisano warunki karty dla stacji')
|
||||
|
||||
@api.get('/stations')
|
||||
@login_required
|
||||
def stations():
|
||||
q=request.args.get('q','').strip();page=request.args.get('page',1,type=int);per=min(request.args.get('per_page',30,type=int),100);query=FuelStationCompany.query.filter_by(active=True)
|
||||
if q:
|
||||
for term in [x for x in q.split() if x]:
|
||||
needle=f'%{term}%'
|
||||
query=query.filter(db.or_(FuelStationCompany.brand_name.ilike(needle),FuelStationCompany.company_name.ilike(needle),FuelStationCompany.nip.ilike(needle),FuelStationCompany.regon.ilike(needle),FuelStationCompany.station_names.ilike(needle)))
|
||||
sort=request.args.get('sort','company_name');direction=request.args.get('direction','asc')
|
||||
allowed={'company_name':FuelStationCompany.brand_name,'station_count':FuelStationCompany.station_count,'nip':FuelStationCompany.nip,'regon':FuelStationCompany.regon,'selected_region':FuelStationCompany.selected_region}
|
||||
col=allowed.get(sort,FuelStationCompany.company_name);query=query.order_by(col.desc() if direction=='desc' else col.asc(),FuelStationCompany.brand_name.asc())
|
||||
p=query.paginate(page=page,per_page=per,error_out=False);return response({'items':[station_dict(x) for x in p.items],'page':p.page,'pages':p.pages,'total':p.total,'sort':sort,'direction':direction})
|
||||
|
||||
@api.get('/stations/<int:station_id>/points')
|
||||
@login_required
|
||||
def station_points(station_id):
|
||||
station=FuelStationCompany.query.get_or_404(station_id)
|
||||
page=max(request.args.get('page',1,type=int),1)
|
||||
per_page=min(max(request.args.get('per_page',50,type=int),1),100)
|
||||
q=(request.args.get('q') or '').strip()
|
||||
query=FuelStationPoint.query.filter_by(station_company_id=station.id)
|
||||
if q:
|
||||
needle=f'%{q}%'
|
||||
query=query.filter(db.or_(FuelStationPoint.station_name.ilike(needle),FuelStationPoint.street.ilike(needle),FuelStationPoint.city.ilike(needle),FuelStationPoint.postal_code.ilike(needle)))
|
||||
query=query.order_by(FuelStationPoint.city.asc(),FuelStationPoint.street.asc(),FuelStationPoint.street_number.asc())
|
||||
result=query.paginate(page=page,per_page=per_page,error_out=False)
|
||||
items=[{'id':x.id,'dkn':x.ure_dkn.split(':',1)[0] if x.ure_dkn else '','name':x.station_name,'address':x.address,'city':x.city,'region':x.region,'coordinates':x.coordinates,'has_petrol':x.has_petrol,'has_diesel':x.has_diesel,'has_lpg':x.has_lpg} for x in result.items]
|
||||
return response({'station':station_dict(station),'items':items,'page':result.page,'pages':result.pages,'total':result.total})
|
||||
|
||||
@api.put('/me/favorite-stations')
|
||||
@login_required
|
||||
def favorite_stations():
|
||||
d=payload();ids=request.form.getlist('station_ids') if request.form else d.get('station_ids',[]);ids=[ids] if isinstance(ids,(str,int)) else ids
|
||||
try: ids=[int(x) for x in ids]
|
||||
except Exception: return fail('Nieprawidłowa lista stacji')
|
||||
if len(ids)>10:return fail('Użytkownik może mieć maksymalnie 10 ulubionych stacji')
|
||||
current_user.favorite_stations=FuelStationCompany.query.filter(FuelStationCompany.id.in_(ids),FuelStationCompany.active.is_(True)).all() if ids else []
|
||||
db.session.commit();socketio.emit('app_changed',{'resource':'favorite_stations','user_id':current_user.id});return response([station_dict(x) for x in current_user.favorite_stations],'Zapisano ulubione stacje')
|
||||
|
||||
@api.put('/settings/allowed-stations')
|
||||
@role_required('boss','admin')
|
||||
def allowed_stations():
|
||||
d=payload();ids=request.form.getlist('station_ids') if request.form else d.get('station_ids',[]);ids=[ids] if isinstance(ids,(str,int)) else ids
|
||||
try: ids=[int(x) for x in ids]
|
||||
except Exception: return fail('Nieprawidłowa lista stacji')
|
||||
settings=CompanySettings.query.first();settings.allowed_stations=FuelStationCompany.query.filter(FuelStationCompany.id.in_(ids),FuelStationCompany.active.is_(True)).all() if ids else []
|
||||
db.session.commit();socketio.emit('app_changed',{'resource':'allowed_stations'});return response([station_dict(x) for x in settings.allowed_stations],'Zapisano dozwolone stacje')
|
||||
|
||||
@api.post('/stations/sync')
|
||||
@role_required('boss','admin')
|
||||
def stations_sync():
|
||||
try:
|
||||
companies=aggregate_ure_companies(fetch_ure_stations())
|
||||
added,updated=sync_station_catalog(companies)
|
||||
db.session.commit();socketio.emit('app_changed',{'resource':'stations'});return response({'added':added,'updated':updated},f'Katalog URE: dodano {added}, zaktualizowano {updated}')
|
||||
except Exception as exc:db.session.rollback();return fail(f'Nie udało się pobrać danych URE: {exc}',502)
|
||||
|
||||
@api.put('/stations/<int:station_id>')
|
||||
@role_required('boss','admin')
|
||||
def update_station(station_id):
|
||||
s=FuelStationCompany.query.get_or_404(station_id);d=payload();region=(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)
|
||||
s.selected_region=region;s.active=boolv(d,'active');allowed=boolv(d,'allowed')
|
||||
if allowed and s not in settings.allowed_stations: settings.allowed_stations.append(s)
|
||||
if not allowed and s in settings.allowed_stations: settings.allowed_stations.remove(s)
|
||||
db.session.commit();socketio.emit('app_changed',{'resource':'stations','company_id':settings.id});return response(station_dict(s),f'Zapisano ustawienia stacji dla firmy {settings.name}')
|
||||
|
||||
@api.get('/companies')
|
||||
@role_required('boss','admin')
|
||||
def companies_list():
|
||||
return response([{'id':c.id,'name':c.name,'entity_type':c.entity_type,'region':c.region,'active':c.active,'users_count':len(c.users),'cards_count':FuelCard.query.filter_by(company_id=c.id).count()} for c in CompanySettings.query.order_by(CompanySettings.name).all()])
|
||||
|
||||
@api.post('/companies')
|
||||
@role_required('admin')
|
||||
def companies_create():
|
||||
d=payload()
|
||||
try:
|
||||
c=CompanySettings(name=(d.get('name') or '').strip(),entity_type=d.get('entity_type','JDG'),vat_rate=Decimal(d.get('vat_rate') or 23),vat_deduction_percent=Decimal(d.get('vat_deduction_percent') or 50),region=(d.get('region') or 'mazowieckie').lower(),invoice_split_day=int(d.get('invoice_split_day') or 15),active=True)
|
||||
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.put('/companies/<int:company_id>')
|
||||
@role_required('boss','admin')
|
||||
def companies_update(company_id):
|
||||
c=CompanySettings.query.get_or_404(company_id);d=payload()
|
||||
if current_user.role=='boss' and current_user.company_id!=c.id:return fail('Brak uprawnień',403)
|
||||
try:
|
||||
c.name=(d.get('name') or c.name).strip();c.entity_type=d.get('entity_type',c.entity_type);c.vat_rate=Decimal(d.get('vat_rate') or c.vat_rate);c.vat_deduction_percent=Decimal(d.get('vat_deduction_percent') or c.vat_deduction_percent);c.region=(d.get('region') or c.region).lower();c.invoice_split_day=int(d.get('invoice_split_day') or c.invoice_split_day);c.active=boolv(d,'active')
|
||||
db.session.commit();socketio.emit('app_changed',{'resource':'company','id':c.id});return response(message='Zapisano firmę')
|
||||
except Exception as exc:db.session.rollback();return fail(str(exc))
|
||||
|
||||
@api.put('/companies/<int:company_id>/favorite-stations')
|
||||
@role_required('boss','admin')
|
||||
def company_favorites(company_id):
|
||||
c=CompanySettings.query.get_or_404(company_id)
|
||||
if current_user.role=='boss' and current_user.company_id!=c.id:return fail('Brak uprawnień',403)
|
||||
d=payload();ids=request.form.getlist('station_ids') if request.form else d.get('station_ids',[]);ids=[ids] if isinstance(ids,(str,int)) else ids
|
||||
try: ids=[int(x) for x in ids]
|
||||
except Exception:return fail('Nieprawidłowa lista stacji')
|
||||
if len(ids)>25:return fail('Firma może mieć maksymalnie 25 ulubionych stacji')
|
||||
c.favorite_stations=FuelStationCompany.query.filter(FuelStationCompany.id.in_(ids),FuelStationCompany.active.is_(True)).all() if ids else []
|
||||
db.session.commit();socketio.emit('app_changed',{'resource':'company_favorites','company_id':c.id});return response(message='Zapisano ulubione stacje firmy')
|
||||
|
||||
@api.post('/companies/<int:company_id>/favorite-stations/<int:station_id>/toggle')
|
||||
@role_required('boss','admin')
|
||||
def company_favorite_toggle(company_id,station_id):
|
||||
c=CompanySettings.query.get_or_404(company_id);s=FuelStationCompany.query.get_or_404(station_id)
|
||||
if current_user.role=='boss' and current_user.company_id!=c.id:return fail('Brak uprawnień',403)
|
||||
if s in c.favorite_stations:c.favorite_stations.remove(s);state=False
|
||||
else:
|
||||
if len(c.favorite_stations)>=25:return fail('Firma może mieć maksymalnie 25 ulubionych stacji')
|
||||
c.favorite_stations.append(s);state=True
|
||||
db.session.commit();socketio.emit('app_changed',{'resource':'company_favorites','company_id':c.id});return response({'favorite':state},'Zmieniono ulubione stacje')
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
from flask import Blueprint, render_template, request, redirect, url_for, flash
|
||||
from flask_login import login_user, logout_user, login_required
|
||||
from .models import User
|
||||
|
||||
auth = Blueprint("auth", __name__)
|
||||
|
||||
@auth.route("/login", methods=["GET", "POST"])
|
||||
def login():
|
||||
if request.method == "POST":
|
||||
user = User.query.filter_by(email=request.form["email"].strip().lower()).first()
|
||||
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")
|
||||
@login_required
|
||||
def logout():
|
||||
logout_user(); return redirect(url_for("auth.login"))
|
||||
@@ -0,0 +1,73 @@
|
||||
import json
|
||||
import os
|
||||
|
||||
|
||||
def env(name, default=None):
|
||||
value = os.getenv(name)
|
||||
return default if value is None or value == "" else value
|
||||
|
||||
|
||||
def env_float(name, default):
|
||||
try:
|
||||
return float(env(name, default))
|
||||
except (TypeError, ValueError):
|
||||
return float(default)
|
||||
|
||||
|
||||
def env_int(name, default):
|
||||
try:
|
||||
return int(env(name, default))
|
||||
except (TypeError, ValueError):
|
||||
return int(default)
|
||||
|
||||
|
||||
def env_json(name, default):
|
||||
raw = env(name)
|
||||
if raw is None:
|
||||
return default
|
||||
try:
|
||||
return json.loads(raw)
|
||||
except json.JSONDecodeError:
|
||||
return default
|
||||
|
||||
|
||||
class Config:
|
||||
SECRET_KEY = env("SECRET_KEY", "dev-secret")
|
||||
SQLALCHEMY_DATABASE_URI = env("DATABASE_URL", "sqlite:///fueltrack.db")
|
||||
SQLALCHEMY_TRACK_MODIFICATIONS = False
|
||||
|
||||
HTTP_TIMEOUT_SECONDS = env_float("HTTP_TIMEOUT_SECONDS", 30)
|
||||
ORLEN_TIMEOUT_SECONDS = env_float("ORLEN_TIMEOUT_SECONDS", 15)
|
||||
URE_TIMEOUT_SECONDS = env_float("URE_TIMEOUT_SECONDS", 45)
|
||||
|
||||
ORLEN_WHOLESALE_URL = env("ORLEN_WHOLESALE_URL", "https://tool.orlen.pl/api/wholesalefuelprices/ByProduct")
|
||||
ORLEN_LPG_URL = env("ORLEN_LPG_URL", "https://tool.orlen.pl/api/autogasprices")
|
||||
ORLEN_LPG_BY_DATE_URL = env("ORLEN_LPG_BY_DATE_URL", "https://tool.orlen.pl/api/autogasprices/ByDate")
|
||||
URE_STATIONS_URL = env("URE_STATIONS_URL", "https://api.ure.gov.pl/api/InfrastructureFuelStation")
|
||||
|
||||
BOOTSTRAP_JS_URL = env("BOOTSTRAP_JS_URL", "https://cdn.jsdelivr.net/npm/bootstrap@5.3.7/dist/js/bootstrap.bundle.min.js")
|
||||
CHOICES_CSS_URL = env("CHOICES_CSS_URL", "https://cdn.jsdelivr.net/npm/choices.js@11.1.0/public/assets/styles/choices.min.css")
|
||||
CHOICES_JS_URL = env("CHOICES_JS_URL", "https://cdn.jsdelivr.net/npm/choices.js@11.1.0/public/assets/scripts/choices.min.js")
|
||||
CHART_JS_URL = env("CHART_JS_URL", "https://cdn.jsdelivr.net/npm/chart.js@4.5.0/dist/chart.umd.min.js")
|
||||
SOCKET_IO_JS_URL = env("SOCKET_IO_JS_URL", "https://cdn.socket.io/4.8.1/socket.io.min.js")
|
||||
|
||||
THEME_BOOTSTRAP_URL = env("THEME_BOOTSTRAP_URL", "https://cdn.jsdelivr.net/npm/bootstrap@5.3.7/dist/css/bootstrap.min.css")
|
||||
THEME_FLATLY_URL = env("THEME_FLATLY_URL", "https://cdn.jsdelivr.net/npm/bootswatch@5.3.7/dist/flatly/bootstrap.min.css")
|
||||
THEME_DARKLY_URL = env("THEME_DARKLY_URL", "https://cdn.jsdelivr.net/npm/bootswatch@5.3.7/dist/darkly/bootstrap.min.css")
|
||||
THEME_PULSE_URL = env("THEME_PULSE_URL", "https://cdn.jsdelivr.net/npm/bootswatch@5.3.7/dist/pulse/bootstrap.min.css")
|
||||
|
||||
STATION_BRAND_ALIASES = env_json("STATION_BRAND_ALIASES_JSON", {
|
||||
"ORLEN": "ORLEN",
|
||||
"HIL-GAZ": "HIL-GAZ",
|
||||
"HILL-GAZ": "HIL-GAZ",
|
||||
"BP ": "BP",
|
||||
"BP EUROPA": "BP",
|
||||
"SHELL": "Shell",
|
||||
"MOL POLSKA": "MOL",
|
||||
"CIRCLE K": "Circle K",
|
||||
"ANWIM": "MOYA",
|
||||
"AMIC": "AMIC Energy",
|
||||
"LOTOS": "LOTOS",
|
||||
"AVIA": "AVIA",
|
||||
"PIEPRZYK": "Pieprzyk",
|
||||
})
|
||||
@@ -0,0 +1,14 @@
|
||||
from decimal import Decimal, ROUND_HALF_UP
|
||||
|
||||
|
||||
def money(value):
|
||||
return Decimal(str(value)).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)
|
||||
|
||||
|
||||
def calculate_costs(gross, vat_rate=23, vat_deduction_percent=50):
|
||||
gross = Decimal(str(gross)); rate = Decimal(str(vat_rate))
|
||||
deduction = Decimal(str(vat_deduction_percent)) / Decimal("100")
|
||||
net = gross / (Decimal("1") + rate / Decimal("100")); vat = gross - net
|
||||
deductible_vat = vat * deduction
|
||||
return {"gross": money(gross), "net": money(net), "vat": money(vat),
|
||||
"deductible_vat": money(deductible_vat), "final_cost": money(gross - deductible_vat)}
|
||||
@@ -0,0 +1,2 @@
|
||||
def invoice_period(dt, split_day=15):
|
||||
return f"{dt:%Y-%m}-01–{split_day:02d}" if dt.day <= split_day else f"{dt:%Y-%m}-{split_day + 1:02d}–koniec"
|
||||
@@ -0,0 +1,8 @@
|
||||
from flask_sqlalchemy import SQLAlchemy
|
||||
from flask_login import LoginManager
|
||||
from flask_socketio import SocketIO
|
||||
|
||||
db = SQLAlchemy()
|
||||
login_manager = LoginManager()
|
||||
login_manager.login_view = "auth.login"
|
||||
socketio = SocketIO(async_mode="threading", cors_allowed_origins="*", ping_timeout=60, ping_interval=25)
|
||||
@@ -0,0 +1,9 @@
|
||||
import requests
|
||||
from flask import current_app
|
||||
|
||||
|
||||
def get_json(url, params=None, timeout_key="HTTP_TIMEOUT_SECONDS"):
|
||||
timeout = float(current_app.config.get(timeout_key, current_app.config["HTTP_TIMEOUT_SECONDS"]))
|
||||
response = requests.get(url, params=params, timeout=timeout)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
@@ -0,0 +1,70 @@
|
||||
from datetime import date, timedelta
|
||||
from decimal import Decimal
|
||||
from flask import current_app
|
||||
from .http import get_json
|
||||
|
||||
PRODUCT_IDS = {"PB95": 41, "PB98": 42, "DIESEL": 43}
|
||||
POLISH_REGIONS = (
|
||||
"dolnośląskie", "kujawsko-pomorskie", "lubelskie", "lubuskie", "łódzkie",
|
||||
"małopolskie", "mazowieckie", "opolskie", "podkarpackie", "podlaskie",
|
||||
"pomorskie", "śląskie", "świętokrzyskie", "warmińsko-mazurskie",
|
||||
"wielkopolskie", "zachodnio-pomorskie",
|
||||
)
|
||||
|
||||
|
||||
def _rows(url, params=None):
|
||||
data = get_json(url, params=params, timeout_key="ORLEN_TIMEOUT_SECONDS")
|
||||
if not isinstance(data, list):
|
||||
raise ValueError("Nieprawidłowa odpowiedź API Orlen")
|
||||
return data
|
||||
|
||||
|
||||
def fetch_orlen_range(fuel_type, date_from, date_to, region="mazowieckie"):
|
||||
fuel_type = fuel_type.upper()
|
||||
if fuel_type == "LPG":
|
||||
result, seen = [], set()
|
||||
current = date_from
|
||||
while current <= date_to:
|
||||
rows = _rows(current_app.config["ORLEN_LPG_BY_DATE_URL"], {"date": current.isoformat()})
|
||||
for row in rows:
|
||||
row_region = (row.get("locationName") or "").strip().lower()
|
||||
if region and str(region).lower() not in ("all", "*") and row_region != str(region).lower():
|
||||
continue
|
||||
effective = date.fromisoformat(row["effectiveDate"][:10])
|
||||
if not (date_from <= effective <= date_to):
|
||||
continue
|
||||
key = (effective, row_region)
|
||||
if key in seen:
|
||||
continue
|
||||
seen.add(key)
|
||||
value = Decimal(str(row["value"]))
|
||||
result.append({"fuel_type": "LPG", "effective_date": effective, "price_per_liter": value,
|
||||
"raw_value": value, "region": row_region, "product_name": row.get("productName") or "LPG",
|
||||
"source": f"Orlen LPG ByDate, {row_region}"})
|
||||
current += timedelta(days=1)
|
||||
return result
|
||||
|
||||
product_id = PRODUCT_IDS.get(fuel_type)
|
||||
if not product_id:
|
||||
raise ValueError("Nieobsługiwany rodzaj paliwa")
|
||||
rows = _rows(current_app.config["ORLEN_WHOLESALE_URL"], {
|
||||
"productId": product_id, "from": date_from.isoformat(), "to": date_to.isoformat()
|
||||
})
|
||||
result = []
|
||||
for row in rows:
|
||||
effective = date.fromisoformat(row["effectiveDate"][:10])
|
||||
raw = Decimal(str(row["value"]))
|
||||
result.append({"fuel_type": fuel_type, "effective_date": effective, "price_per_liter": raw / Decimal("1000"),
|
||||
"raw_value": raw, "region": "", "product_name": row.get("productName") or fuel_type,
|
||||
"source": f"Orlen {row.get('productName') or fuel_type}, netto/m³ → zł/l"})
|
||||
return result
|
||||
|
||||
|
||||
def fetch_orlen_price(fuel_type, day=None, region="mazowieckie"):
|
||||
day = day or date.today()
|
||||
rows = fetch_orlen_range(fuel_type, day.replace(day=1), day, region)
|
||||
candidates = [row for row in rows if row["effective_date"] <= day]
|
||||
if not candidates:
|
||||
raise ValueError("Brak ceny Orlen dla wybranej daty")
|
||||
row = max(candidates, key=lambda item: item["effective_date"])
|
||||
return {"price_per_liter": float(row["price_per_liter"]), "date": row["effective_date"].isoformat(), "source": row["source"]}
|
||||
@@ -0,0 +1,112 @@
|
||||
import hashlib
|
||||
import re
|
||||
from flask import current_app
|
||||
from .http import get_json
|
||||
|
||||
LEGAL_SUFFIXES = re.compile(r"\b(SPÓŁKA|SPÓŁKA AKCYJNA|SP\. ?Z ?O\. ?O\.|S\. ?A\.|ODDZIAŁ|SPÓŁKA EUROPEJSKA)\b.*$", re.I)
|
||||
|
||||
|
||||
def fetch_ure_stations():
|
||||
payload = get_json(current_app.config["URE_STATIONS_URL"], timeout_key="URE_TIMEOUT_SECONDS")
|
||||
if isinstance(payload, list):
|
||||
return payload
|
||||
if isinstance(payload, dict):
|
||||
for key in ("items", "results", "data", "value"):
|
||||
if isinstance(payload.get(key), list):
|
||||
return payload[key]
|
||||
raise ValueError("Nieprawidłowa odpowiedź API URE")
|
||||
|
||||
|
||||
def _clean(value):
|
||||
return " ".join(str(value or "").strip().split())
|
||||
|
||||
|
||||
def infer_brand(company_name, station_name=""):
|
||||
combined = f"{_clean(company_name)} {_clean(station_name)}".upper()
|
||||
aliases = current_app.config.get("STATION_BRAND_ALIASES", {})
|
||||
# Najpierw szukamy marki w nazwie konkretnej stacji, później w przedsiębiorcy.
|
||||
for token, label in sorted(aliases.items(), key=lambda item: len(item[0]), reverse=True):
|
||||
if token.upper() in combined:
|
||||
return label
|
||||
normalized = _clean(company_name)
|
||||
shortened = LEGAL_SUFFIXES.sub("", normalized).strip(" ,-\"")
|
||||
return shortened or normalized or "Nieznana sieć"
|
||||
|
||||
|
||||
def _address_parts(row):
|
||||
return [
|
||||
_clean(row.get("infraUlica")),
|
||||
_clean(row.get("infraNrLokalu")),
|
||||
_clean(row.get("infraKod")) or _clean(row.get("kod")),
|
||||
_clean(row.get("infraPoczta")) or _clean(row.get("miejscowosc")) or _clean(row.get("poczta")),
|
||||
_clean(row.get("wojewodztwo")).lower(),
|
||||
]
|
||||
|
||||
|
||||
def _point_key(row, index):
|
||||
address = "|".join(x.casefold() for x in _address_parts(row) if x)
|
||||
station_name = _clean(row.get("nazwaStacji")).casefold()
|
||||
raw = address or f"{station_name}|{index}"
|
||||
digest = hashlib.sha1(raw.encode("utf-8")).hexdigest()[:16]
|
||||
dkn = _clean(row.get("dkn"))
|
||||
storage_key = f"{dkn or 'no-dkn'}:{digest}"
|
||||
return raw, storage_key
|
||||
|
||||
|
||||
def _merge_point(target, source):
|
||||
for field in ("station_name", "street", "street_number", "postal_code", "city", "region", "coordinates"):
|
||||
if not target.get(field) and source.get(field):
|
||||
target[field] = source[field]
|
||||
for field in ("has_petrol", "has_diesel", "has_lpg"):
|
||||
target[field] = bool(target.get(field) or source.get(field))
|
||||
|
||||
|
||||
def aggregate_ure_companies(rows):
|
||||
grouped = {}
|
||||
aliases = {label.casefold() for label in current_app.config.get("STATION_BRAND_ALIASES", {}).values()}
|
||||
for index, row in enumerate(rows, start=1):
|
||||
nip, regon = _clean(row.get("nip")), _clean(row.get("regon"))
|
||||
company_name = _clean(row.get("nazwa")) or "Nieznana firma"
|
||||
station_name = _clean(row.get("nazwaStacji"))
|
||||
brand = infer_brand(company_name, station_name)
|
||||
# Rozpoznane sieci agregujemy jako jedną markę, niezależnie od operatora/franczyzobiorcy.
|
||||
if brand.casefold() in aliases:
|
||||
key = f"brand:{brand.casefold()}"
|
||||
else:
|
||||
key = f"nip:{nip}" if nip else (f"regon:{regon}" if regon else f"name:{company_name.casefold()}")
|
||||
item = grouped.setdefault(key, {
|
||||
"ure_key": key,
|
||||
"brand_name": brand,
|
||||
"company_name": brand if key.startswith("brand:") else company_name,
|
||||
"nip": None if key.startswith("brand:") else (nip or None),
|
||||
"regon": None if key.startswith("brand:") else (regon or None),
|
||||
"station_names": set(), "regions": set(),
|
||||
"has_petrol": False, "has_diesel": False, "has_lpg": False,
|
||||
"points": {},
|
||||
})
|
||||
if station_name: item["station_names"].add(station_name)
|
||||
region = _clean(row.get("wojewodztwo")).lower()
|
||||
if region: item["regions"].add(region)
|
||||
item["has_petrol"] |= bool(row.get("benzynySilnikowe"))
|
||||
item["has_diesel"] |= bool(row.get("olejeNapedowe"))
|
||||
item["has_lpg"] |= bool(row.get("gazPlynnyLPG"))
|
||||
point_key, stored_dkn = _point_key(row, index)
|
||||
point = {
|
||||
"ure_dkn": stored_dkn,
|
||||
"station_name": station_name or brand,
|
||||
"street": _clean(row.get("infraUlica")), "street_number": _clean(row.get("infraNrLokalu")),
|
||||
"postal_code": _clean(row.get("infraKod")) or _clean(row.get("kod")),
|
||||
"city": _clean(row.get("infraPoczta")) or _clean(row.get("miejscowosc")) or _clean(row.get("poczta")),
|
||||
"region": region, "coordinates": _clean(row.get("wspolrzedne")),
|
||||
"has_petrol": bool(row.get("benzynySilnikowe")), "has_diesel": bool(row.get("olejeNapedowe")), "has_lpg": bool(row.get("gazPlynnyLPG")),
|
||||
}
|
||||
existing = item["points"].get(point_key)
|
||||
_merge_point(existing, point) if existing else item["points"].__setitem__(point_key, point)
|
||||
result = []
|
||||
for item in grouped.values():
|
||||
item["points"] = list(item["points"].values())
|
||||
item["station_count"] = len(item["points"])
|
||||
item["station_names"] = "|".join(sorted(item["station_names"], key=str.casefold))
|
||||
item["regions"] = "|".join(sorted(item["regions"], key=str.casefold))
|
||||
result.append(item)
|
||||
return sorted(result, key=lambda x: (x["brand_name"].casefold(), x["company_name"].casefold()))
|
||||
+393
@@ -0,0 +1,393 @@
|
||||
from collections import defaultdict
|
||||
from datetime import datetime, date
|
||||
from decimal import Decimal
|
||||
from functools import wraps
|
||||
from flask import Blueprint, render_template, request, redirect, url_for, flash, jsonify, abort
|
||||
from flask_login import login_required, current_user
|
||||
from sqlalchemy import extract
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from .extensions import db, socketio
|
||||
from .models import User, Vehicle, FuelEntry, CompanySettings, AppSetting, OrlenPrice, FuelCardPolicy, FuelStationCompany, FuelStationPoint, FuelCard, FuelCardStationRule
|
||||
from .services import calculate_costs, fetch_orlen_price, fetch_orlen_range, invoice_period, fetch_ure_stations, aggregate_ure_companies, POLISH_REGIONS
|
||||
from .station_catalog import sync_station_catalog
|
||||
from . import THEMES
|
||||
|
||||
main = Blueprint("main", __name__)
|
||||
FUEL_TYPES = ("PB95", "PB98", "DIESEL", "LPG")
|
||||
|
||||
|
||||
def wants_json():
|
||||
return request.headers.get("X-Requested-With") == "XMLHttpRequest" or request.is_json
|
||||
|
||||
def ok_response(message, **extra):
|
||||
socketio.emit("app_changed", {"message": message})
|
||||
if wants_json():
|
||||
return jsonify({"ok": True, "message": message, **extra})
|
||||
flash(message, "success")
|
||||
return None
|
||||
|
||||
def error_response(message, status=400):
|
||||
if wants_json():
|
||||
return jsonify({"ok": False, "error": message}), status
|
||||
flash(message, "danger")
|
||||
return None
|
||||
|
||||
def roles(*allowed):
|
||||
def deco(fn):
|
||||
@wraps(fn)
|
||||
def wrapped(*a, **kw):
|
||||
if current_user.role not in allowed:
|
||||
flash("Brak uprawnień", "danger")
|
||||
return redirect(url_for("main.dashboard"))
|
||||
return fn(*a, **kw)
|
||||
return wrapped
|
||||
return deco
|
||||
|
||||
def accessible_vehicles():
|
||||
if current_user.role == "admin":
|
||||
return Vehicle.query.order_by(Vehicle.name).all()
|
||||
if current_user.role == "boss" and current_user.company_id:
|
||||
return Vehicle.query.filter_by(company_id=current_user.company_id).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 []
|
||||
|
||||
@main.route("/")
|
||||
@login_required
|
||||
def dashboard():
|
||||
settings = CompanySettings.query.first(); vehicles = accessible_vehicles(); ids = [v.id for v in vehicles]
|
||||
split_enabled = (db.session.get(AppSetting, "invoice_split_enabled") or AppSetting(key="invoice_split_enabled", value="1")).value == "1"
|
||||
month = request.args.get("month", datetime.now().strftime("%Y-%m"))
|
||||
try: year, mon = map(int, month.split("-"))
|
||||
except ValueError: year, mon = datetime.now().year, datetime.now().month; month=f"{year:04d}-{mon:02d}"
|
||||
entries = FuelEntry.query.filter(FuelEntry.vehicle_id.in_(ids), extract("year", FuelEntry.fueled_at)==year, extract("month", FuelEntry.fueled_at)==mon).order_by(FuelEntry.fueled_at.desc()).all() if ids else []
|
||||
totals = {"gross": Decimal("0"), "deductible_vat": Decimal("0"), "final_cost": Decimal("0"), "liters": Decimal("0")}
|
||||
by_vehicle = defaultdict(lambda: {"gross": 0, "payable": 0, "liters": 0}); invoices = defaultdict(lambda: {"gross": 0, "payable": 0, "count": 0}); settlements = {}
|
||||
for e in entries:
|
||||
c = calculate_costs(e.gross, settings.vat_rate, settings.vat_deduction_percent)
|
||||
totals["gross"] += c["gross"]; totals["deductible_vat"] += c["deductible_vat"]; totals["final_cost"] += c["final_cost"]; totals["liters"] += Decimal(e.liters)
|
||||
station_policy = e.station_company
|
||||
normal_price = float(e.price_per_liter)
|
||||
payable_price = normal_price
|
||||
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
|
||||
uses_station_last_price = bool(rule and rule.use_orlen_last_price and e.wholesale_price is not None)
|
||||
if uses_station_last_price:
|
||||
payable_price = float(e.wholesale_price) * (1 + float(settings.vat_rate) / 100)
|
||||
if rule:
|
||||
net = payable_price / (1 + float(settings.vat_rate) / 100)
|
||||
net = net * (1 - float(rule.discount_net_percent or 0) / 100) + float(rule.surcharge_net_per_liter or 0)
|
||||
payable_price = net * (1 + float(settings.vat_rate) / 100)
|
||||
payable_price = max(payable_price, 0)
|
||||
payable_gross = float(e.liters) * payable_price
|
||||
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}
|
||||
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)
|
||||
if e.used_fuel_card:
|
||||
key = invoice_period(e.fueled_at, settings.invoice_split_day) if split_enabled else "Cały miesiąc"
|
||||
else:
|
||||
key = "Poza kartą"
|
||||
invoices[key]["gross"] += float(c["gross"]); invoices[key]["payable"] += payable_gross; invoices[key]["count"] += 1
|
||||
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)
|
||||
|
||||
@main.route("/vehicles", methods=["GET", "POST"])
|
||||
@login_required
|
||||
def vehicles():
|
||||
if request.method == "POST":
|
||||
v = Vehicle(name=request.form["name"], make=request.form["make"], model=request.form["model"], registration=request.form["registration"].upper(), fuel_type=request.form["fuel_type"], owner_id=current_user.id, has_fuel_card="has_fuel_card" in request.form, card_provider=request.form.get("card_provider"), current_odometer=int(request.form.get("current_odometer") or 0))
|
||||
db.session.add(v); db.session.commit(); response = ok_response("Dodano pojazd", vehicle_id=v.id); return response or redirect(url_for("main.vehicles"))
|
||||
company_id=current_user.company_id
|
||||
users_q=User.query.filter_by(active=True)
|
||||
cards_q=FuelCard.query.filter_by(active=True)
|
||||
if current_user.role=='boss' and company_id:
|
||||
users_q=users_q.filter_by(company_id=company_id);cards_q=cards_q.filter_by(company_id=company_id)
|
||||
return render_template("vehicles.html", vehicles=accessible_vehicles(), fuel_cards=cards_q.order_by(FuelCard.name).all(), assignable_users=users_q.order_by(User.name).all())
|
||||
|
||||
@main.route("/vehicles/<int:vehicle_id>/share", methods=["POST"])
|
||||
@login_required
|
||||
def share_vehicle(vehicle_id):
|
||||
vehicle = Vehicle.query.get_or_404(vehicle_id)
|
||||
if current_user.role != "admin" and vehicle.owner_id != current_user.id:
|
||||
response = error_response("Tylko właściciel lub admin może udostępniać auto", 403); return response or redirect(url_for("main.vehicles"))
|
||||
user = User.query.filter_by(email=request.form["email"].strip().lower()).first()
|
||||
if not user:
|
||||
response = error_response("Nie znaleziono użytkownika", 404); return response or redirect(url_for("main.vehicles"))
|
||||
if user not in vehicle.drivers: vehicle.drivers.append(user); db.session.commit()
|
||||
response = ok_response("Udostępniono pojazd"); return response or redirect(url_for("main.vehicles"))
|
||||
|
||||
|
||||
@main.post("/vehicles/<int:vehicle_id>/unshare/<int:user_id>")
|
||||
@login_required
|
||||
def unshare_vehicle(vehicle_id, user_id):
|
||||
vehicle = Vehicle.query.get_or_404(vehicle_id)
|
||||
if current_user.role != "admin" and vehicle.owner_id != current_user.id:
|
||||
return error_response("Tylko właściciel lub admin może odebrać dostęp", 403)
|
||||
user = User.query.get_or_404(user_id)
|
||||
if user in vehicle.drivers:
|
||||
vehicle.drivers.remove(user); db.session.commit()
|
||||
response = ok_response("Odebrano dostęp do pojazdu")
|
||||
return response or redirect(url_for("main.vehicles"))
|
||||
|
||||
@main.post("/vehicles/<int:vehicle_id>/card-policy")
|
||||
@login_required
|
||||
def update_card_policy(vehicle_id):
|
||||
vehicle = Vehicle.query.get_or_404(vehicle_id)
|
||||
if current_user.role != "admin" and vehicle.owner_id != current_user.id:
|
||||
return error_response("Tylko właściciel lub admin może zmienić zasady rozliczeń", 403)
|
||||
policy = vehicle.fuel_card_policy or FuelCardPolicy(vehicle=vehicle)
|
||||
policy.use_orlen_last_price = "use_orlen_last_price" in request.form
|
||||
policy.discount_percent = Decimal(request.form.get("discount_percent") or 0)
|
||||
policy.surcharge_per_liter = Decimal(request.form.get("surcharge_per_liter") or 0)
|
||||
policy.description = request.form.get("description", "").strip() or None
|
||||
db.session.add(policy); db.session.commit()
|
||||
response = ok_response("Zapisano zasady rozliczeń karty")
|
||||
return response or redirect(url_for("main.vehicles"))
|
||||
|
||||
@main.route("/fuel", methods=["GET", "POST"])
|
||||
@login_required
|
||||
def fuel():
|
||||
vehicles = accessible_vehicles()
|
||||
if request.method == "POST":
|
||||
vehicle = next((v for v in vehicles if v.id == int(request.form["vehicle_id"])), None)
|
||||
if not vehicle:
|
||||
response = error_response("Brak dostępu do pojazdu", 403); return response or redirect(url_for("main.fuel"))
|
||||
odometer = int(request.form["odometer"])
|
||||
if odometer < vehicle.current_odometer:
|
||||
response = error_response("Licznik nie może być niższy od poprzedniego"); return response or redirect(url_for("main.fuel"))
|
||||
fueled_at = datetime.fromisoformat(request.form["fueled_at"]); wholesale = None; source = None
|
||||
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
|
||||
price_region = station_company.selected_region if station_company and station_company.selected_region else CompanySettings.query.first().region
|
||||
try:
|
||||
p = fetch_orlen_price(request.form["fuel_type"], fueled_at.date(), price_region); wholesale=p["price_per_liter"]; source=p["source"]
|
||||
except Exception: pass
|
||||
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)
|
||||
vehicle.current_odometer = odometer; db.session.add(e); db.session.commit(); socketio.emit("fuel_added", {"vehicle": vehicle.name, "gross": round(e.gross,2)}); response = ok_response("Zapisano tankowanie", redirect=url_for("main.dashboard")); return response or redirect(url_for("main.dashboard"))
|
||||
settings=current_user.company or CompanySettings.query.first()
|
||||
allowed=list(settings.allowed_stations)
|
||||
base_query=FuelStationCompany.query.filter_by(active=True)
|
||||
stations=(base_query.filter(FuelStationCompany.id.in_([x.id for x in allowed])) if allowed else base_query).order_by(FuelStationCompany.station_count.desc(), FuelStationCompany.brand_name.asc()).all()
|
||||
favorites=[x for x in current_user.favorite_stations if x in stations]
|
||||
favorite_scope="user"
|
||||
if not favorites and settings.favorite_stations:
|
||||
favorites=[x for x in settings.favorite_stations if x in stations];favorite_scope="company"
|
||||
if not favorites: favorites=sorted(stations,key=lambda x:(-x.station_count,x.company_name.lower()))[:10];favorite_scope="default"
|
||||
favorites=sorted(favorites,key=lambda x:(-x.station_count,x.display_name.lower()))
|
||||
favorite_ids={x.id for x in favorites}
|
||||
favorite_catalog=sorted(stations,key=lambda x:(-x.station_count,x.display_name.lower()))
|
||||
other_stations=sorted((x for x in stations if x.id not in favorite_ids),key=lambda x:x.display_name.lower())
|
||||
return render_template("fuel_form.html", vehicles=vehicles, stations=stations, favorite_stations=favorites, favorite_catalog=favorite_catalog, other_stations=other_stations, now=datetime.now().strftime("%Y-%m-%dT%H:%M"), restricted=bool(allowed), favorite_scope=favorite_scope, company=settings, fuel_cards=FuelCard.query.filter_by(active=True,company_id=settings.id).order_by(FuelCard.name).all())
|
||||
|
||||
@main.route("/api/orlen-price")
|
||||
@login_required
|
||||
def orlen_price():
|
||||
try: return jsonify(fetch_orlen_price(request.args["fuel_type"], datetime.fromisoformat(request.args["date"]).date(), CompanySettings.query.first().region))
|
||||
except Exception as exc: return jsonify({"error": str(exc)}), 502
|
||||
|
||||
@main.route("/orlen")
|
||||
@login_required
|
||||
def orlen_data():
|
||||
settings = CompanySettings.query.first()
|
||||
selected_year = request.args.get("year", type=int) or date.today().year
|
||||
selected_fuels = [f.upper() for f in request.args.getlist("fuel") if f.upper() in FUEL_TYPES] or ["PB95"]
|
||||
include_vat = request.args.get("gross") == "1"
|
||||
show_lpg_regions = request.args.get('show_lpg_regions') == '1'
|
||||
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 show_lpg_regions: 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()
|
||||
chart = {}
|
||||
for fuel in selected_fuels:
|
||||
fuel_rows = [r for r in rows if r.fuel_type == fuel and (fuel != "LPG" or r.region in selected_regions)]
|
||||
multiplier = 1 + float(settings.vat_rate) / 100 if include_vat else 1
|
||||
chart_key = fuel if fuel != "LPG" else None
|
||||
if fuel == "LPG":
|
||||
for region in selected_regions:
|
||||
chart[f"LPG · {region}"] = [{"date": r.effective_date.isoformat(), "value": round(float(r.price_per_liter) * multiplier, 4)} for r in fuel_rows if r.region == region]
|
||||
else:
|
||||
chart[fuel] = [{"date": r.effective_date.isoformat(), "value": round(float(r.price_per_liter) * multiplier, 4)} for r in fuel_rows]
|
||||
return render_template("orlen.html", rows=rows, chart=chart, selected_fuels=selected_fuels, selected_year=selected_year, settings=settings, fuel_types=FUEL_TYPES, include_vat=include_vat, regions=POLISH_REGIONS, selected_regions=selected_regions, show_lpg_regions=show_lpg_regions)
|
||||
|
||||
@main.post("/orlen/sync")
|
||||
@login_required
|
||||
@roles("boss", "admin")
|
||||
def orlen_sync():
|
||||
payload = request.get_json(silent=True) or request.form
|
||||
fuels = payload.get("fuels", [])
|
||||
if isinstance(fuels, str): fuels = [fuels]
|
||||
fuels = [f.upper() for f in fuels if f.upper() in FUEL_TYPES]
|
||||
year = int(payload.get("year", date.today().year))
|
||||
if not fuels: return jsonify({"ok": False, "error": "Wybierz co najmniej jedno paliwo."}), 400
|
||||
settings = CompanySettings.query.first(); start = date(year,1,1); end = min(date(year,12,31), date.today())
|
||||
result = {}; total = 0
|
||||
try:
|
||||
for fuel in fuels:
|
||||
imported = 0; updated = 0
|
||||
for row in fetch_orlen_range(fuel, start, end, "all" if fuel == "LPG" else settings.region):
|
||||
existing = OrlenPrice.query.filter_by(fuel_type=row["fuel_type"], effective_date=row["effective_date"], region=row["region"]).first()
|
||||
if existing:
|
||||
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
|
||||
else:
|
||||
db.session.add(OrlenPrice(**row)); imported += 1
|
||||
result[fuel] = {"added": imported, "updated": updated}; total += imported + updated
|
||||
db.session.commit()
|
||||
return jsonify({"ok": True, "message": f"Przetworzono {total} rekordów.", "result": result})
|
||||
except Exception as exc:
|
||||
db.session.rollback(); return jsonify({"ok": False, "error": str(exc)}), 502
|
||||
|
||||
@main.route("/admin", methods=["GET","POST"])
|
||||
@login_required
|
||||
@roles("boss", "admin")
|
||||
def admin():
|
||||
if request.method=="POST":
|
||||
settings=current_user.company or CompanySettings.query.first()
|
||||
settings.name=request.form.get("name",settings.name);settings.entity_type=request.form.get("entity_type",settings.entity_type);settings.vat_rate=Decimal(request.form.get("vat_rate",settings.vat_rate));settings.vat_deduction_percent=Decimal(request.form.get("vat_deduction_percent",settings.vat_deduction_percent));settings.region=request.form.get("region",settings.region);settings.invoice_split_day=int(request.form.get("invoice_split_day",settings.invoice_split_day))
|
||||
theme=request.form.get("theme")
|
||||
if theme in THEMES:db.session.merge(AppSetting(key="theme",value=theme))
|
||||
db.session.commit();return ok_response("Zapisano") or redirect(url_for("main.admin_companies"))
|
||||
return redirect(url_for("main.admin_companies"))
|
||||
|
||||
@main.get("/admin/companies")
|
||||
@login_required
|
||||
@roles("boss", "admin")
|
||||
def admin_companies():
|
||||
q = request.args.get("q", "").strip()
|
||||
page = request.args.get("page", 1, type=int)
|
||||
selected_id = request.args.get("company_id", type=int)
|
||||
companies_query = CompanySettings.query
|
||||
if current_user.role == "boss":
|
||||
companies_query = companies_query.filter_by(id=current_user.company_id)
|
||||
elif q:
|
||||
companies_query = companies_query.filter(CompanySettings.name.ilike(f"%{q}%"))
|
||||
companies_page = companies_query.order_by(CompanySettings.name.asc()).paginate(
|
||||
page=page, per_page=20, error_out=False
|
||||
)
|
||||
visible_companies = companies_page.items
|
||||
if current_user.role == "boss":
|
||||
selected_company = current_user.company
|
||||
else:
|
||||
selected_company = db.session.get(CompanySettings, selected_id) if selected_id else None
|
||||
if selected_company is None:
|
||||
selected_company = visible_companies[0] if visible_companies else CompanySettings.query.order_by(CompanySettings.name.asc()).first()
|
||||
if selected_company and current_user.role == "boss" and selected_company.id != current_user.company_id:
|
||||
abort(403)
|
||||
fuel_cards = FuelCard.query.filter_by(company_id=selected_company.id).order_by(FuelCard.name.asc()).all() if selected_company else []
|
||||
stations = FuelStationCompany.query.filter_by(active=True).order_by(
|
||||
FuelStationCompany.station_count.desc(), FuelStationCompany.brand_name.asc()
|
||||
).all()
|
||||
return render_template(
|
||||
"admin_companies.html",
|
||||
companies_page=companies_page,
|
||||
selected_company=selected_company,
|
||||
fuel_cards=fuel_cards,
|
||||
q=q,
|
||||
regions=POLISH_REGIONS,
|
||||
stations=stations,
|
||||
)
|
||||
|
||||
@main.get("/admin/application")
|
||||
@login_required
|
||||
@roles("admin")
|
||||
def admin_application():
|
||||
return render_template("admin_application.html")
|
||||
|
||||
@main.get("/admin/users")
|
||||
@login_required
|
||||
@roles("boss", "admin")
|
||||
def admin_users():
|
||||
page=request.args.get("page",1,type=int);q=request.args.get("q","").strip();users_q=User.query
|
||||
if current_user.role=="boss": users_q=users_q.filter_by(company_id=current_user.company_id)
|
||||
if q: users_q=users_q.filter(db.or_(User.name.ilike(f"%{q}%"),User.email.ilike(f"%{q}%")))
|
||||
users_page=users_q.order_by(User.name).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]
|
||||
cards=FuelCard.query.order_by(FuelCard.name).all()
|
||||
if current_user.role=="boss": cards=cards.filter_by(company_id=current_user.company_id).all()
|
||||
return render_template("admin_users.html",users_page=users_page,q=q,companies=[c for c in companies if c],fuel_cards=cards)
|
||||
|
||||
@main.post("/admin/users/<int:user_id>")
|
||||
@login_required
|
||||
@roles("admin")
|
||||
def edit_user(user_id):
|
||||
user = User.query.get_or_404(user_id)
|
||||
try:
|
||||
user.name = request.form["name"].strip(); user.email = request.form["email"].strip().lower(); user.role = request.form["role"]
|
||||
user.active = "active" in request.form
|
||||
password = request.form.get("password", "")
|
||||
if password:
|
||||
if len(password) < 8: raise ValueError("Nowe hasło musi mieć co najmniej 8 znaków.")
|
||||
user.set_password(password)
|
||||
db.session.commit(); response = ok_response(f"Zaktualizowano użytkownika {user.email}"); return response or redirect(url_for("main.admin"))
|
||||
except (ValueError, IntegrityError) as exc:
|
||||
db.session.rollback(); message = str(exc.orig) if isinstance(exc, IntegrityError) else str(exc); response = error_response(message); return response or redirect(url_for("main.admin", _anchor=f"user-{user_id}"))
|
||||
|
||||
|
||||
@main.route("/stations")
|
||||
@login_required
|
||||
@roles("boss", "admin")
|
||||
def stations():
|
||||
q = request.args.get("q", "").strip()
|
||||
page = request.args.get("page", 1, type=int)
|
||||
sort = request.args.get("sort", "company_name")
|
||||
direction = request.args.get("direction", "asc")
|
||||
query = FuelStationCompany.query.filter_by(active=True)
|
||||
if q:
|
||||
terms = [term for term in q.split() if term]
|
||||
for term in terms:
|
||||
pattern = f"%{term}%"
|
||||
query = query.filter(db.or_(FuelStationCompany.brand_name.ilike(pattern), FuelStationCompany.company_name.ilike(pattern), FuelStationCompany.nip.ilike(pattern), FuelStationCompany.regon.ilike(pattern), FuelStationCompany.station_names.ilike(pattern)))
|
||||
columns = {"company_name": FuelStationCompany.brand_name, "station_count": FuelStationCompany.station_count, "nip": FuelStationCompany.nip, "regon": FuelStationCompany.regon, "selected_region": FuelStationCompany.selected_region}
|
||||
column = columns.get(sort, FuelStationCompany.company_name)
|
||||
query = query.order_by(column.desc() if direction == "desc" else column.asc(), FuelStationCompany.brand_name.asc())
|
||||
station_page = query.paginate(page=page, per_page=30, error_out=False)
|
||||
companies_q = CompanySettings.query.filter_by(active=True).order_by(CompanySettings.name.asc())
|
||||
if current_user.role == "boss":
|
||||
companies_q = companies_q.filter_by(id=current_user.company_id)
|
||||
companies = companies_q.all()
|
||||
requested_company_id = request.args.get("company_id", type=int)
|
||||
if current_user.role == "boss":
|
||||
company = current_user.company
|
||||
else:
|
||||
company = db.session.get(CompanySettings, requested_company_id) if requested_company_id else None
|
||||
if company not in companies:
|
||||
company = companies[0] if companies else None
|
||||
if company is None:
|
||||
abort(404, description="Brak aktywnej firmy do konfiguracji")
|
||||
cards_q=FuelCard.query.filter_by(active=True, company_id=company.id)
|
||||
cards=cards_q.order_by(FuelCard.name).all()
|
||||
rules={(r.fuel_card_id,r.station_company_id):r for r in FuelCardStationRule.query.filter(FuelCardStationRule.fuel_card_id.in_([c.id for c in cards] or [-1])).all()}
|
||||
return render_template("stations.html", station_page=station_page, q=q, regions=POLISH_REGIONS, sort=sort, direction=direction, allowed_ids={x.id for x in company.allowed_stations}, favorite_ids={x.id for x in company.favorite_stations}, company=company, companies=companies, fuel_cards=cards, card_rules=rules)
|
||||
|
||||
@main.post("/stations/sync")
|
||||
@login_required
|
||||
@roles("boss", "admin")
|
||||
def stations_sync():
|
||||
try:
|
||||
companies = aggregate_ure_companies(fetch_ure_stations())
|
||||
added, updated = sync_station_catalog(companies)
|
||||
db.session.commit()
|
||||
response = ok_response(f"Katalog URE: dodano {added}, zaktualizowano {updated}.")
|
||||
return response or redirect(url_for("main.stations"))
|
||||
except Exception as exc:
|
||||
db.session.rollback(); response = error_response(f"Nie udało się pobrać danych URE: {exc}", 502); return response or redirect(url_for("main.stations"))
|
||||
|
||||
@main.post("/stations/<int:station_id>")
|
||||
@login_required
|
||||
@roles("boss", "admin")
|
||||
def update_station(station_id):
|
||||
station = FuelStationCompany.query.get_or_404(station_id)
|
||||
region = request.form.get("selected_region", "").strip().lower()
|
||||
if region and region not in station.region_list:
|
||||
return error_response("Wybrane województwo nie występuje w danych tej firmy.")
|
||||
station.selected_region = region
|
||||
station.active = "active" in request.form
|
||||
db.session.commit(); response = ok_response(f"Zapisano ustawienia firmy {station.company_name}"); return response or redirect(url_for("main.stations"))
|
||||
|
||||
@main.get("/api/stations")
|
||||
@login_required
|
||||
def station_suggestions():
|
||||
q = request.args.get("q", "").strip()
|
||||
query = FuelStationCompany.query.filter_by(active=True)
|
||||
if q:
|
||||
pattern = f"%{q}%"
|
||||
query = query.filter(db.or_(FuelStationCompany.brand_name.ilike(pattern), FuelStationCompany.company_name.ilike(pattern), FuelStationCompany.station_names.ilike(pattern), FuelStationCompany.nip.ilike(pattern)))
|
||||
rows = query.order_by(FuelStationCompany.company_name).limit(30).all()
|
||||
return jsonify([{"id": x.id, "name": x.company_name, "brands": x.station_name_list[:5], "region": x.selected_region, "last_price": x.use_orlen_last_price} for x in rows])
|
||||
+205
@@ -0,0 +1,205 @@
|
||||
from datetime import datetime
|
||||
from flask_login import UserMixin
|
||||
from werkzeug.security import generate_password_hash, check_password_hash
|
||||
from .extensions import db
|
||||
|
||||
vehicle_users = db.Table(
|
||||
"vehicle_users",
|
||||
db.Column("vehicle_id", db.Integer, db.ForeignKey("vehicle.id"), primary_key=True),
|
||||
db.Column("user_id", db.Integer, db.ForeignKey("user.id"), primary_key=True),
|
||||
)
|
||||
|
||||
user_favorite_stations = db.Table(
|
||||
"user_favorite_stations",
|
||||
db.Column("user_id", db.Integer, db.ForeignKey("user.id"), primary_key=True),
|
||||
db.Column("station_company_id", db.Integer, db.ForeignKey("fuel_station_company.id"), primary_key=True),
|
||||
)
|
||||
|
||||
company_favorite_stations = db.Table(
|
||||
"company_favorite_stations",
|
||||
db.Column("settings_id", db.Integer, db.ForeignKey("company_settings.id"), primary_key=True),
|
||||
db.Column("station_company_id", db.Integer, db.ForeignKey("fuel_station_company.id"), primary_key=True),
|
||||
)
|
||||
|
||||
company_allowed_stations = db.Table(
|
||||
"company_allowed_stations",
|
||||
db.Column("settings_id", db.Integer, db.ForeignKey("company_settings.id"), primary_key=True),
|
||||
db.Column("station_company_id", db.Integer, db.ForeignKey("fuel_station_company.id"), primary_key=True),
|
||||
)
|
||||
|
||||
class User(UserMixin, db.Model):
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
email = db.Column(db.String(160), unique=True, nullable=False, index=True)
|
||||
name = db.Column(db.String(120), nullable=False)
|
||||
password_hash = db.Column(db.String(255), nullable=False)
|
||||
role = db.Column(db.String(20), nullable=False, default="user")
|
||||
active = db.Column(db.Boolean, default=True, nullable=False)
|
||||
fuel_card_id = db.Column(db.Integer, db.ForeignKey("fuel_card.id"), index=True)
|
||||
company_id = db.Column(db.Integer, db.ForeignKey("company_settings.id"), index=True)
|
||||
created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False)
|
||||
owned_vehicles = db.relationship("Vehicle", back_populates="owner", foreign_keys="Vehicle.owner_id")
|
||||
shared_vehicles = db.relationship("Vehicle", secondary=vehicle_users, back_populates="drivers")
|
||||
fuel_card = db.relationship("FuelCard", foreign_keys=[fuel_card_id])
|
||||
company = db.relationship("CompanySettings", foreign_keys=[company_id], back_populates="users")
|
||||
|
||||
def set_password(self, password): self.password_hash = generate_password_hash(password)
|
||||
def check_password(self, password): return check_password_hash(self.password_hash, password)
|
||||
@property
|
||||
def is_active(self): return self.active
|
||||
|
||||
class CompanySettings(db.Model):
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
name = db.Column(db.String(160), default="Moja firma", nullable=False)
|
||||
entity_type = db.Column(db.String(30), default="JDG", nullable=False)
|
||||
vat_rate = db.Column(db.Numeric(5, 2), default=23, nullable=False)
|
||||
vat_deduction_percent = db.Column(db.Numeric(5, 2), default=50, nullable=False)
|
||||
region = db.Column(db.String(60), default="mazowieckie", nullable=False)
|
||||
invoice_split_day = db.Column(db.Integer, default=15, nullable=False)
|
||||
active = db.Column(db.Boolean, default=True, nullable=False)
|
||||
users = db.relationship("User", back_populates="company", foreign_keys="User.company_id")
|
||||
|
||||
class AppSetting(db.Model):
|
||||
key = db.Column(db.String(80), primary_key=True)
|
||||
value = db.Column(db.String(255), nullable=False)
|
||||
|
||||
class FuelCard(db.Model):
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
name = db.Column(db.String(120), nullable=False, unique=True, index=True)
|
||||
provider = db.Column(db.String(120), nullable=False)
|
||||
active = db.Column(db.Boolean, default=True, nullable=False)
|
||||
description = db.Column(db.String(255))
|
||||
company_id = db.Column(db.Integer, db.ForeignKey("company_settings.id"), index=True)
|
||||
created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False)
|
||||
station_rules = db.relationship("FuelCardStationRule", back_populates="fuel_card", cascade="all, delete-orphan")
|
||||
company = db.relationship("CompanySettings", foreign_keys=[company_id])
|
||||
|
||||
class Vehicle(db.Model):
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
name = db.Column(db.String(120), nullable=False)
|
||||
make = db.Column(db.String(80), nullable=False)
|
||||
model = db.Column(db.String(80), nullable=False)
|
||||
registration = db.Column(db.String(20), unique=True, nullable=False)
|
||||
fuel_type = db.Column(db.String(20), nullable=False, default="PB95")
|
||||
owner_id = db.Column(db.Integer, db.ForeignKey("user.id"), nullable=False)
|
||||
company_id = db.Column(db.Integer, db.ForeignKey("company_settings.id"), index=True)
|
||||
has_fuel_card = db.Column(db.Boolean, default=False, nullable=False)
|
||||
card_provider = db.Column(db.String(80))
|
||||
fuel_card_id = db.Column(db.Integer, db.ForeignKey("fuel_card.id"), index=True)
|
||||
current_odometer = db.Column(db.Integer, default=0, nullable=False)
|
||||
owner = db.relationship("User", back_populates="owned_vehicles", foreign_keys=[owner_id])
|
||||
drivers = db.relationship("User", secondary=vehicle_users, back_populates="shared_vehicles")
|
||||
fuel_card = db.relationship("FuelCard", foreign_keys=[fuel_card_id])
|
||||
company = db.relationship("CompanySettings", foreign_keys=[company_id])
|
||||
fuel_entries = db.relationship("FuelEntry", back_populates="vehicle", cascade="all, delete-orphan")
|
||||
|
||||
class FuelStationCompany(db.Model):
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
ure_key = db.Column(db.String(180), unique=True, nullable=False, index=True)
|
||||
brand_name = db.Column(db.String(120), nullable=False, default="", index=True)
|
||||
company_name = db.Column(db.String(220), nullable=False, index=True)
|
||||
nip = db.Column(db.String(20), index=True)
|
||||
regon = db.Column(db.String(20), index=True)
|
||||
station_names = db.Column(db.Text, default="", nullable=False)
|
||||
regions = db.Column(db.Text, default="", nullable=False)
|
||||
selected_region = db.Column(db.String(60), default="", nullable=False)
|
||||
station_count = db.Column(db.Integer, default=0, nullable=False)
|
||||
has_petrol = db.Column(db.Boolean, default=False, nullable=False)
|
||||
has_diesel = db.Column(db.Boolean, default=False, nullable=False)
|
||||
has_lpg = db.Column(db.Boolean, default=False, nullable=False)
|
||||
use_orlen_last_price = db.Column(db.Boolean, default=False, nullable=False)
|
||||
active = db.Column(db.Boolean, default=True, nullable=False)
|
||||
fetched_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False)
|
||||
points = db.relationship("FuelStationPoint", back_populates="station_company", cascade="all, delete-orphan", lazy="dynamic")
|
||||
|
||||
@property
|
||||
def region_list(self): return [x for x in (self.regions or "").split("|") if x]
|
||||
@property
|
||||
def station_name_list(self): return [x for x in (self.station_names or "").split("|") if x]
|
||||
@property
|
||||
def display_name(self): return self.brand_name or self.company_name
|
||||
|
||||
|
||||
class FuelStationPoint(db.Model):
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
station_company_id = db.Column(db.Integer, db.ForeignKey("fuel_station_company.id"), nullable=False, index=True)
|
||||
ure_dkn = db.Column(db.String(40), nullable=False, index=True)
|
||||
station_name = db.Column(db.String(220), nullable=False, default="")
|
||||
street = db.Column(db.String(180), nullable=False, default="")
|
||||
street_number = db.Column(db.String(40), nullable=False, default="")
|
||||
postal_code = db.Column(db.String(20), nullable=False, default="")
|
||||
city = db.Column(db.String(120), nullable=False, default="")
|
||||
region = db.Column(db.String(60), nullable=False, default="")
|
||||
coordinates = db.Column(db.String(120), nullable=False, default="")
|
||||
has_petrol = db.Column(db.Boolean, default=False, nullable=False)
|
||||
has_diesel = db.Column(db.Boolean, default=False, nullable=False)
|
||||
has_lpg = db.Column(db.Boolean, default=False, nullable=False)
|
||||
station_company = db.relationship("FuelStationCompany", back_populates="points")
|
||||
__table_args__ = (db.UniqueConstraint("station_company_id", "ure_dkn", name="uq_station_company_point_dkn"),)
|
||||
|
||||
@property
|
||||
def address(self):
|
||||
line = " ".join(x for x in (self.street, self.street_number) if x).strip()
|
||||
locality = " ".join(x for x in (self.postal_code, self.city) if x).strip()
|
||||
return ", ".join(x for x in (line, locality) if x) or "Brak adresu"
|
||||
|
||||
class FuelCardStationRule(db.Model):
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
fuel_card_id = db.Column(db.Integer, db.ForeignKey("fuel_card.id"), nullable=False, index=True)
|
||||
station_company_id = db.Column(db.Integer, db.ForeignKey("fuel_station_company.id"), nullable=False, index=True)
|
||||
allowed = db.Column(db.Boolean, default=True, nullable=False)
|
||||
use_orlen_last_price = db.Column(db.Boolean, default=False, nullable=False)
|
||||
discount_net_percent = db.Column(db.Numeric(6, 3), default=0, nullable=False)
|
||||
surcharge_net_per_liter = db.Column(db.Numeric(10, 4), default=0, nullable=False)
|
||||
fuel_card = db.relationship("FuelCard", back_populates="station_rules")
|
||||
station_company = db.relationship("FuelStationCompany")
|
||||
__table_args__ = (db.UniqueConstraint("fuel_card_id", "station_company_id", name="uq_card_station_rule"),)
|
||||
|
||||
class FuelEntry(db.Model):
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
vehicle_id = db.Column(db.Integer, db.ForeignKey("vehicle.id"), nullable=False, index=True)
|
||||
user_id = db.Column(db.Integer, db.ForeignKey("user.id"), nullable=False)
|
||||
fueled_at = db.Column(db.DateTime, nullable=False, default=datetime.utcnow, index=True)
|
||||
liters = db.Column(db.Numeric(10, 3), nullable=False)
|
||||
price_per_liter = db.Column(db.Numeric(10, 4), nullable=False)
|
||||
fuel_type = db.Column(db.String(20), nullable=False)
|
||||
odometer = db.Column(db.Integer, nullable=False)
|
||||
station = db.Column(db.String(120))
|
||||
station_company_id = db.Column(db.Integer, db.ForeignKey("fuel_station_company.id"), index=True)
|
||||
fuel_card_id = db.Column(db.Integer, db.ForeignKey("fuel_card.id"), index=True)
|
||||
invoice_number = db.Column(db.String(80))
|
||||
used_fuel_card = db.Column(db.Boolean, default=False, nullable=False)
|
||||
wholesale_price = db.Column(db.Numeric(10, 4))
|
||||
wholesale_source = db.Column(db.String(120))
|
||||
created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False)
|
||||
vehicle = db.relationship("Vehicle", back_populates="fuel_entries")
|
||||
user = db.relationship("User")
|
||||
station_company = db.relationship("FuelStationCompany")
|
||||
fuel_card = db.relationship("FuelCard")
|
||||
@property
|
||||
def gross(self): return float(self.liters) * float(self.price_per_liter)
|
||||
|
||||
class OrlenPrice(db.Model):
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
fuel_type = db.Column(db.String(20), nullable=False, index=True)
|
||||
effective_date = db.Column(db.Date, nullable=False, index=True)
|
||||
price_per_liter = db.Column(db.Numeric(10, 4), nullable=False)
|
||||
raw_value = db.Column(db.Numeric(14, 4), nullable=False)
|
||||
region = db.Column(db.String(60), nullable=False, default="")
|
||||
product_name = db.Column(db.String(80), nullable=False)
|
||||
source = db.Column(db.String(160), nullable=False)
|
||||
fetched_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False)
|
||||
__table_args__ = (db.UniqueConstraint("fuel_type", "effective_date", "region", name="uq_orlen_price"),)
|
||||
|
||||
class FuelCardPolicy(db.Model):
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
vehicle_id = db.Column(db.Integer, db.ForeignKey("vehicle.id"), nullable=False, unique=True, index=True)
|
||||
use_orlen_last_price = db.Column(db.Boolean, default=False, nullable=False)
|
||||
discount_percent = db.Column(db.Numeric(6, 3), default=0, nullable=False)
|
||||
surcharge_per_liter = db.Column(db.Numeric(10, 4), default=0, nullable=False)
|
||||
description = db.Column(db.String(160))
|
||||
vehicle = db.relationship("Vehicle", backref=db.backref("fuel_card_policy", uselist=False, cascade="all, delete-orphan"))
|
||||
|
||||
User.favorite_stations = db.relationship("FuelStationCompany", secondary=user_favorite_stations, backref=db.backref("favorite_users", lazy="dynamic"))
|
||||
CompanySettings.allowed_stations = db.relationship("FuelStationCompany", secondary=company_allowed_stations, backref=db.backref("allowed_for_companies", lazy="dynamic"))
|
||||
|
||||
CompanySettings.favorite_stations = db.relationship("FuelStationCompany", secondary=company_favorite_stations, backref=db.backref("favorite_for_companies", lazy="dynamic"))
|
||||
@@ -0,0 +1,46 @@
|
||||
from flask import Blueprint, jsonify, Response
|
||||
|
||||
docs = Blueprint('openapi', __name__)
|
||||
|
||||
@docs.get('/openapi.json')
|
||||
def spec():
|
||||
return jsonify({
|
||||
'openapi':'3.0.3','info':{'title':'FuelTrack API','version':'0.0.1','description':'REST API używane przez interfejs FuelTrack. Autoryzacja odbywa się przez sesję Flask (cookie).'},
|
||||
'components':{'securitySchemes':{'sessionCookie':{'type':'apiKey','in':'cookie','name':'session'}},'schemas':{
|
||||
'ApiResponse':{'type':'object','properties':{'ok':{'type':'boolean'},'message':{'type':'string'},'data':{}}},
|
||||
'User':{'type':'object','properties':{'id':{'type':'integer'},'name':{'type':'string'},'email':{'type':'string','format':'email'},'role':{'type':'string','enum':['user','boss','admin']},'active':{'type':'boolean'}}},
|
||||
'Vehicle':{'type':'object','properties':{'id':{'type':'integer'},'name':{'type':'string'},'registration':{'type':'string'},'fuel_type':{'type':'string','enum':['PB95','PB98','DIESEL','LPG']}}}
|
||||
}},
|
||||
'security':[{'sessionCookie':[]}],
|
||||
'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/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/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/{user_id}':{'delete':{'tags':['Vehicles'],'summary':'Odebranie dostępu','parameters':[{'in':'path','name':'vehicle_id','required':True,'schema':{'type':'integer'}},{'in':'path','name':'user_id','required':True,'schema':{'type':'integer'}}],'responses':{'200':{'description':'Odebrano'}}}},
|
||||
'/api/vehicles/{vehicle_id}/fuel-card':{'put':{'tags':['Vehicles'],'summary':'Przypisanie karty paliwowej do pojazdu','parameters':[{'in':'path','name':'vehicle_id','required':True,'schema':{'type':'integer'}}],'responses':{'200':{'description':'Zapisano'}}}},
|
||||
'/api/fuel-entries':{'post':{'tags':['Fuel entries'],'summary':'Dodanie tankowania','responses':{'201':{'description':'Utworzono'}}}},
|
||||
'/api/orlen/prices':{'get':{'tags':['Orlen'],'summary':'Ceny zapisane w bazie','parameters':[{'in':'query','name':'year','schema':{'type':'integer'}},{'in':'query','name':'fuel','schema':{'type':'array','items':{'type':'string'}}},{'in':'query','name':'region','schema':{'type':'array','items':{'type':'string'}}}],'responses':{'200':{'description':'Lista cen'}}}},
|
||||
'/api/orlen/sync':{'post':{'tags':['Orlen'],'summary':'Import cen Orlen','responses':{'200':{'description':'Zaimportowano'}}}},
|
||||
'/api/orlen/preview':{'get':{'tags':['Orlen'],'summary':'Cena na dzień','parameters':[{'in':'query','name':'fuel_type','required':True,'schema':{'type':'string'}},{'in':'query','name':'date','required':True,'schema':{'type':'string','format':'date'}}],'responses':{'200':{'description':'Cena'}}}},
|
||||
'/api/users':{'get':{'tags':['Users'],'summary':'Lista użytkowników','responses':{'200':{'description':'Lista'}}},'post':{'tags':['Users'],'summary':'Dodanie użytkownika','responses':{'201':{'description':'Utworzono'}}}},
|
||||
'/api/users/{user_id}':{'put':{'tags':['Users'],'summary':'Edycja użytkownika','parameters':[{'in':'path','name':'user_id','required':True,'schema':{'type':'integer'}}],'responses':{'200':{'description':'Zapisano'}}},'delete':{'tags':['Users'],'summary':'Usunięcie użytkownika','description':'Nie pozwala usunąć własnego konta, ostatniego aktywnego administratora ani użytkownika z pojazdami lub historią tankowań.','parameters':[{'in':'path','name':'user_id','required':True,'schema':{'type':'integer'}}],'responses':{'200':{'description':'Usunięto'},'409':{'description':'Usunięcie zablokowane'}}}},
|
||||
'/api/settings':{'get':{'tags':['Settings'],'summary':'Pobranie ustawień','responses':{'200':{'description':'Ustawienia'}}},'put':{'tags':['Settings'],'summary':'Zapis ustawień','responses':{'200':{'description':'Zapisano'}}}},
|
||||
'/api/app-settings':{'get':{'tags':['Settings'],'summary':'Ustawienia aplikacji','responses':{'200':{'description':'Ustawienia'}}},'put':{'tags':['Settings'],'summary':'Zmiana motywu aplikacji','responses':{'200':{'description':'Zapisano'}}}},
|
||||
'/api/fuel-cards':{'get':{'tags':['Fuel cards'],'summary':'Lista kart paliwowych','responses':{'200':{'description':'Lista'}}},'post':{'tags':['Fuel cards'],'summary':'Dodanie karty paliwowej','responses':{'201':{'description':'Utworzono'}}}},
|
||||
'/api/fuel-cards/{card_id}':{'put':{'tags':['Fuel cards'],'summary':'Edycja karty paliwowej','parameters':[{'in':'path','name':'card_id','required':True,'schema':{'type':'integer'}}],'responses':{'200':{'description':'Zapisano'}}}},
|
||||
'/api/fuel-cards/{card_id}/stations/{station_id}':{'put':{'tags':['Fuel cards'],'summary':'Warunki karty dla stacji: blokada, last price, rabat netto i dopłata','parameters':[{'in':'path','name':'card_id','required':True,'schema':{'type':'integer'}},{'in':'path','name':'station_id','required':True,'schema':{'type':'integer'}}],'responses':{'200':{'description':'Zapisano'}}}},
|
||||
'/api/stations':{'get':{'tags':['Stations'],'summary':'Lista firm paliwowych','parameters':[{'in':'query','name':'q','schema':{'type':'string'}},{'in':'query','name':'sort','schema':{'type':'string','enum':['company_name','station_count','nip','regon','selected_region']}},{'in':'query','name':'direction','schema':{'type':'string','enum':['asc','desc']}},{'in':'query','name':'page','schema':{'type':'integer'}}],'responses':{'200':{'description':'Lista'}}}},
|
||||
'/api/stations/{station_id}/points':{'get':{'tags':['Stations'],'summary':'Lista fizycznych punktów wybranej firmy paliwowej','parameters':[{'in':'path','name':'station_id','required':True,'schema':{'type':'integer'}},{'in':'query','name':'q','schema':{'type':'string'}},{'in':'query','name':'page','schema':{'type':'integer'}}],'responses':{'200':{'description':'Lista punktów i adresów'}}}},
|
||||
'/api/me/favorite-stations':{'put':{'tags':['Stations'],'summary':'Zapis maksymalnie 10 ulubionych sieci bieżącego użytkownika','responses':{'200':{'description':'Zapisano'}}}},
|
||||
'/api/settings/allowed-stations':{'put':{'tags':['Stations'],'summary':'Zapis listy sieci dozwolonych przez firmę','responses':{'200':{'description':'Zapisano'}}}},
|
||||
'/api/stations/sync':{'post':{'tags':['Stations'],'summary':'Import i agregacja URE','responses':{'200':{'description':'Zaimportowano'}}}},
|
||||
'/api/stations/{station_id}':{'put':{'tags':['Stations'],'summary':'Edycja firmy paliwowej','parameters':[{'in':'path','name':'station_id','required':True,'schema':{'type':'integer'}}],'responses':{'200':{'description':'Zapisano'}}}}
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@docs.get('/docs')
|
||||
def swagger_docs():
|
||||
return Response("""<!doctype html><html><head><meta charset='utf-8'><title>FuelTrack API Docs</title><link rel='stylesheet' href='https://cdn.jsdelivr.net/npm/swagger-ui-dist@5/swagger-ui.css'></head><body><div id='swagger-ui'></div><script src='https://cdn.jsdelivr.net/npm/swagger-ui-dist@5/swagger-ui-bundle.js'></script><script>SwaggerUIBundle({url:'/openapi.json',dom_id:'#swagger-ui',deepLinking:true,persistAuthorization:true});</script></body></html>""", mimetype='text/html')
|
||||
@@ -0,0 +1,8 @@
|
||||
"""Compatibility facade. New code lives in domain/ and integrations/."""
|
||||
from .domain.costs import calculate_costs, money
|
||||
from .domain.invoices import invoice_period
|
||||
from .integrations.orlen import fetch_orlen_price, fetch_orlen_range, POLISH_REGIONS, PRODUCT_IDS
|
||||
from .integrations.ure import fetch_ure_stations, aggregate_ure_companies
|
||||
|
||||
__all__ = ["calculate_costs", "money", "invoice_period", "fetch_orlen_price", "fetch_orlen_range",
|
||||
"POLISH_REGIONS", "PRODUCT_IDS", "fetch_ure_stations", "aggregate_ure_companies"]
|
||||
@@ -0,0 +1 @@
|
||||
.chart-wrap{height:420px;position:relative;overflow:hidden}.chart-box{height:320px;position:relative;overflow:hidden}.chart-empty{position:absolute;inset:0;display:grid;place-items:center;border:1px dashed var(--bs-border-color);border-radius:var(--bs-border-radius);color:var(--bs-secondary-color)}
|
||||
@@ -0,0 +1,2 @@
|
||||
.choices{margin-bottom:0}.choices__inner,.choices__input,.choices__list--dropdown,.choices__list[aria-expanded]{background-color:var(--bs-body-bg);color:var(--bs-body-color);border-color:var(--bs-border-color)}.choices__input{border-bottom-color:var(--bs-border-color)}.choices__list--dropdown,.choices__list[aria-expanded]{z-index:1080;max-height:260px;overflow:auto}.choices__list--dropdown .choices__item--selectable.is-highlighted,.choices__list[aria-expanded] .choices__item--selectable.is-highlighted{background-color:var(--bs-tertiary-bg)}.lpg-region-modal .modal-body{min-height:420px}.lpg-region-modal .choices__list--dropdown,.lpg-region-modal .choices__list[aria-expanded]{position:absolute;width:100%}
|
||||
[data-station-proposal-select]+.choices .choices__list--dropdown,[data-station-proposal-select]+.choices .choices__list[aria-expanded]{min-width:100%;width:max-content;max-width:min(42rem,90vw)}
|
||||
@@ -0,0 +1 @@
|
||||
.metric strong{display:block;font-size:1.45rem;margin-top:.25rem}.station-brand{font-weight:700}.station-company{font-size:.875rem;color:var(--bs-secondary-color)}
|
||||
@@ -0,0 +1 @@
|
||||
html,body{min-height:100%}body{min-height:100vh}main{min-height:calc(100vh - 57px)}.navbar-brand{white-space:nowrap}#live-alerts{max-width:420px}
|
||||
@@ -0,0 +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'))})})}})();
|
||||
@@ -0,0 +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'})})}})();
|
||||
@@ -0,0 +1 @@
|
||||
(()=>{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()}}}}})}})();
|
||||
@@ -0,0 +1,6 @@
|
||||
window.FuelTrack=window.FuelTrack||{};
|
||||
FuelTrack.charts={};
|
||||
FuelTrack.qs=(s,r=document)=>r.querySelector(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.debounce=(fn,delay=300)=>{let timer;return(...args)=>{clearTimeout(timer);timer=setTimeout(()=>fn(...args),delay)}};
|
||||
@@ -0,0 +1 @@
|
||||
(()=>{const F=FuelTrack;F.bindFuelPreview=()=>{async function update(){const fuel=F.qs('[name=fuel_type]')?.value,date=F.qs('[name=fueled_at]')?.value,box=F.qs('#orlen-preview');if(!fuel||!date||!box)return;box.textContent='Pobieranie ceny Orlen…';try{const r=await fetch(`/api/orlen/preview?fuel_type=${encodeURIComponent(fuel)}&date=${encodeURIComponent(date)}`),d=await r.json(),x=d.data||d;box.textContent=r.ok&&d.ok?`Orlen: ${Number(x.price_per_liter).toFixed(3)} zł/l (${x.date})`:`Brak ceny: ${d.error||d.message}`}catch{box.textContent='Nie udało się pobrać ceny Orlen.'}}['fuel_type','fueled_at'].forEach(n=>F.qs(`[name=${n}]`)?.addEventListener('change',update));update()}})();
|
||||
@@ -0,0 +1 @@
|
||||
FuelTrack.initPage=()=>{FuelTrack.bindAjaxForms();FuelTrack.bindFuelPreview();FuelTrack.bindOrlenSync();FuelTrack.bindUserModal();FuelTrack.bindAuth();FuelTrack.bindStationLiveSearch();FuelTrack.bindStationFavoriteToggles();FuelTrack.bindStationInlineCompany();FuelTrack.bindLpgRegionPicker();FuelTrack.bindFavoriteStations();FuelTrack.bindStationProposalSelect();FuelTrack.bindStationPoints()};window.addEventListener('popstate',()=>FuelTrack.replaceMain(location.href));document.addEventListener('DOMContentLoaded',()=>{FuelTrack.initSocket();FuelTrack.initPage()});
|
||||
@@ -0,0 +1 @@
|
||||
(()=>{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}})}})();
|
||||
@@ -0,0 +1 @@
|
||||
FuelTrack.initSocket=()=>{if(typeof io==='undefined'||window._fuelSocket)return;window._fuelSocket=io({transports:['polling','websocket'],upgrade:true,reconnection:true});window._fuelSocket.on('fuel_added',d=>FuelTrack.notify(`Nowe tankowanie: ${d.vehicle}`,'info'))};
|
||||
@@ -0,0 +1,150 @@
|
||||
(()=>{
|
||||
const F=FuelTrack;
|
||||
let searchController=null,searchSequence=0;
|
||||
const normalize=value=>String(value??'').normalize('NFD').replace(/[\u0300-\u036f]/g,'').toLowerCase().replace(/[^a-z0-9]+/g,' ').trim();
|
||||
const matches=(haystack,query)=>{const source=normalize(haystack),terms=normalize(query).split(/\s+/).filter(Boolean);return terms.every(term=>source.includes(term))};
|
||||
|
||||
F.bindStationLiveSearch=()=>{
|
||||
const form=F.qs('#station-search-form');
|
||||
if(!form||form.dataset.liveBound)return;
|
||||
form.dataset.liveBound='1';
|
||||
const run=F.debounce(async()=>{
|
||||
const seq=++searchSequence;
|
||||
searchController?.abort();
|
||||
searchController=new AbortController();
|
||||
const p=new URLSearchParams(new FormData(form));p.delete('page');
|
||||
const url=`${form.action}?${p}`;
|
||||
try{
|
||||
const r=await fetch(url,{headers:{'X-Requested-With':'XMLHttpRequest'},signal:searchController.signal});
|
||||
if(!r.ok)throw new Error('Nie udało się wyszukać stacji.');
|
||||
const doc=new DOMParser().parseFromString(await r.text(),'text/html');
|
||||
if(seq!==searchSequence)return;
|
||||
const incoming=F.qs('main',doc),current=F.qs('main');
|
||||
if(!incoming||!current)return;
|
||||
bootstrap.Modal.getInstance(F.qs('#stationPointsModal'))?.hide();
|
||||
current.replaceWith(incoming);
|
||||
document.querySelectorAll('.modal-backdrop').forEach(x=>x.remove());
|
||||
document.body.classList.remove('modal-open');
|
||||
document.body.style.removeProperty('padding-right');
|
||||
history.replaceState({},'',url);
|
||||
F.initPage();
|
||||
}catch(e){if(e.name!=='AbortError')F.notify(e.message,'danger')}
|
||||
},300);
|
||||
F.qs('[data-live-search]',form)?.addEventListener('input',run);
|
||||
F.qsa('[data-live-sort]',form).forEach(x=>x.addEventListener('change',run));
|
||||
};
|
||||
|
||||
F.bindFavoriteStations=()=>{
|
||||
F.qsa('.favorite-station-form').forEach(form=>{
|
||||
if(form.dataset.favoriteSearchBound)return;
|
||||
form.dataset.favoriteSearchBound='1';
|
||||
const input=F.qs('.station-modal-search',form),tbody=F.qs('tbody',form);
|
||||
if(!input||!tbody)return;
|
||||
let empty=F.qs('.favorite-search-empty',tbody);
|
||||
if(!empty){
|
||||
empty=document.createElement('tr');empty.className='favorite-search-empty d-none';
|
||||
empty.innerHTML='<td colspan="3" class="text-center text-body-secondary py-4">Brak pasujących stacji.</td>';
|
||||
tbody.append(empty);
|
||||
}
|
||||
const filter=()=>{
|
||||
const rows=F.qsa('tr[data-station-name]',tbody);
|
||||
let visible=0;
|
||||
rows.forEach(row=>{
|
||||
const searchable=row.dataset.stationName||row.textContent;
|
||||
const show=matches(searchable,input.value);
|
||||
row.hidden=!show;
|
||||
row.classList.toggle('d-none',!show);
|
||||
if(show)visible++;
|
||||
});
|
||||
empty.hidden=visible!==0;
|
||||
empty.classList.toggle('d-none',visible!==0);
|
||||
};
|
||||
input.addEventListener('input',filter);
|
||||
input.addEventListener('search',filter);
|
||||
const modal=form.closest('.modal');
|
||||
modal?.addEventListener('shown.bs.modal',()=>{input.focus();filter()});
|
||||
modal?.addEventListener('hidden.bs.modal',()=>{input.value='';filter()});
|
||||
filter();
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
F.bindStationFavoriteToggles=()=>{
|
||||
F.qsa('.station-favorite-toggle').forEach(button=>{
|
||||
if(button.dataset.bound)return;
|
||||
button.dataset.bound='1';
|
||||
button.addEventListener('click',async()=>{
|
||||
const original=button.textContent;
|
||||
button.disabled=true;
|
||||
button.textContent='Zapisywanie…';
|
||||
try{
|
||||
const r=await fetch(button.dataset.url,{method:'POST',headers:{Accept:'application/json','X-Requested-With':'XMLHttpRequest'}});
|
||||
const d=await r.json();
|
||||
if(!r.ok||!d.ok)throw new Error(d.message||d.error||'Nie udało się zmienić ulubionych.');
|
||||
const favorite=Boolean(d.data&&d.data.favorite);
|
||||
button.textContent=favorite?'Usuń z ulubionych':'Dodaj do ulubionych';
|
||||
F.notify(d.message||'Zapisano');
|
||||
}catch(e){button.textContent=original;F.notify(e.message,'danger')}
|
||||
finally{button.disabled=false}
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
// Zachowany jako no-op: formularz tankowania używa natywnego selecta z optgroup.
|
||||
F.bindStationProposalSelect=()=>{};
|
||||
|
||||
F.bindStationPoints=()=>{
|
||||
const modalEl=F.qs('#stationPointsModal');
|
||||
if(!modalEl)return;
|
||||
const search=F.qs('#stationPointsSearch',modalEl);
|
||||
if(search&&!search.dataset.bound){
|
||||
search.dataset.bound='1';
|
||||
search.addEventListener('input',F.debounce(()=>F.loadStationPoints?.(),250));
|
||||
}
|
||||
};
|
||||
|
||||
F.loadStationPoints=async()=>{
|
||||
const modalEl=F.qs('#stationPointsModal');
|
||||
if(!modalEl||!modalEl.dataset.stationId)return;
|
||||
const rows=F.qs('#stationPointsRows',modalEl),empty=F.qs('#stationPointsEmpty',modalEl),loading=F.qs('#stationPointsLoading',modalEl),count=F.qs('#stationPointsCount',modalEl),search=F.qs('#stationPointsSearch',modalEl);
|
||||
const esc=v=>String(v??'').replace(/[&<>'"]/g,c=>({'&':'&','<':'<','>':'>',"'":''','"':'"'}[c]));
|
||||
loading.classList.remove('d-none');empty.classList.add('d-none');rows.innerHTML='';
|
||||
try{
|
||||
const r=await fetch(`/api/stations/${modalEl.dataset.stationId}/points?q=${encodeURIComponent(search.value.trim())}&per_page=100`,{headers:{Accept:'application/json','X-Requested-With':'XMLHttpRequest'}}),d=await r.json();
|
||||
if(!r.ok||!d.ok)throw new Error(d.message||'Nie udało się pobrać punktów');
|
||||
count.textContent=`Liczba punktów: ${d.data.total}`;
|
||||
rows.innerHTML=d.data.items.map(x=>`<tr><td><strong>${esc(x.name)}</strong>${x.dkn?`<div class="small text-body-secondary">DKN: ${esc(x.dkn)}</div>`:''}</td><td>${esc(x.address)}</td><td>${esc(x.region||'—')}</td><td>${x.has_petrol?'<span class="badge text-bg-secondary me-1">benzyna</span>':''}${x.has_diesel?'<span class="badge text-bg-secondary me-1">diesel</span>':''}${x.has_lpg?'<span class="badge text-bg-secondary">LPG</span>':''}</td></tr>`).join('');
|
||||
empty.classList.toggle('d-none',d.data.items.length>0);
|
||||
}catch(e){empty.textContent=e.message;empty.classList.remove('d-none')}
|
||||
finally{loading.classList.add('d-none')}
|
||||
};
|
||||
|
||||
if(!document.documentElement.dataset.stationPointsDelegated){
|
||||
document.documentElement.dataset.stationPointsDelegated='1';
|
||||
document.addEventListener('click',e=>{
|
||||
const b=e.target.closest('.station-points-open');
|
||||
if(!b)return;
|
||||
const modalEl=F.qs('#stationPointsModal');
|
||||
if(!modalEl)return;
|
||||
modalEl.dataset.stationId=b.dataset.stationId;
|
||||
F.qs('#stationPointsTitle',modalEl).textContent=`Punkty: ${b.dataset.stationName}`;
|
||||
F.qs('#stationPointsSearch',modalEl).value='';
|
||||
F.qs('#stationPointsCount',modalEl).textContent='';
|
||||
bootstrap.Modal.getOrCreateInstance(modalEl).show();
|
||||
F.loadStationPoints();
|
||||
});
|
||||
}
|
||||
|
||||
F.bindStationInlineCompany=()=>{
|
||||
F.qsa('.station-inline-company-select').forEach(select=>{
|
||||
if(select.dataset.bound)return;
|
||||
select.dataset.bound='1';
|
||||
select.addEventListener('change',()=>{
|
||||
const url=new URL(location.href);
|
||||
url.searchParams.set('company_id',select.value);
|
||||
url.searchParams.delete('page');
|
||||
F.replaceMain(url.toString(),{push:true}).catch(e=>F.notify(e.message,'danger'));
|
||||
});
|
||||
});
|
||||
};
|
||||
})();
|
||||
@@ -0,0 +1 @@
|
||||
(()=>{const F=FuelTrack;F.bindUserModal=()=>{const form=F.qs('#user-edit-form'),deleteBtn=F.qs('#user-delete-btn');F.qsa('.user-edit-btn').forEach(btn=>{if(btn.dataset.bound)return;btn.dataset.bound='1';btn.addEventListener('click',()=>{form.action=`/api/users/${btn.dataset.id}`;form.dataset.method='PUT';form.dataset.userId=btn.dataset.id;['name','email','role'].forEach(k=>form.elements[k].value=btn.dataset[k]);if(form.elements.fuel_card_id)form.elements.fuel_card_id.value=btn.dataset.card||'';if(form.elements.company_id)form.elements.company_id.value=btn.dataset.company||'';form.elements.password.value='';form.elements.active.checked=btn.dataset.active==='1'})});if(deleteBtn&&!deleteBtn.dataset.bound){deleteBtn.dataset.bound='1';deleteBtn.addEventListener('click',async()=>{const id=form?.dataset.userId;if(!id)return;if(!confirm('Usunąć tego użytkownika? Tej operacji nie można cofnąć.'))return;const old=deleteBtn.innerHTML;deleteBtn.disabled=true;deleteBtn.innerHTML='Usuwanie…';try{const r=await fetch(`/api/users/${id}`,{method:'DELETE',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||'Nie udało się usunąć użytkownika.');F.notify(d.message||'Usunięto użytkownika');bootstrap.Modal.getInstance(form.closest('.modal'))?.hide();await F.replaceMain(location.href)}catch(e){F.notify(e.message,'danger')}finally{deleteBtn.disabled=false;deleteBtn.innerHTML=old}})}}})();
|
||||
@@ -0,0 +1,34 @@
|
||||
from datetime import datetime
|
||||
from .extensions import db
|
||||
from .models import FuelStationCompany, FuelStationPoint
|
||||
|
||||
|
||||
def sync_station_catalog(companies):
|
||||
added = updated = 0
|
||||
incoming_keys = {row["ure_key"] for row in companies}
|
||||
for source in companies:
|
||||
row = dict(source)
|
||||
point_rows = row.pop("points", [])
|
||||
with db.session.no_autoflush:
|
||||
company = FuelStationCompany.query.filter_by(ure_key=row["ure_key"]).first()
|
||||
if company is None:
|
||||
company = FuelStationCompany(**row, active=True)
|
||||
db.session.add(company); db.session.flush(); added += 1
|
||||
else:
|
||||
for key, value in row.items(): setattr(company, key, value)
|
||||
company.active = True
|
||||
company.fetched_at = datetime.utcnow(); updated += 1
|
||||
existing = {p.ure_dkn: p for p in company.points.all()}
|
||||
incoming = set()
|
||||
for data in point_rows:
|
||||
key = data["ure_dkn"]; incoming.add(key)
|
||||
point = existing.get(key)
|
||||
if point is None:
|
||||
point = FuelStationPoint(station_company_id=company.id, ure_dkn=key); db.session.add(point)
|
||||
for field, value in data.items(): setattr(point, field, value)
|
||||
for key, point in existing.items():
|
||||
if key not in incoming: db.session.delete(point)
|
||||
company.station_count = len(incoming)
|
||||
FuelStationCompany.query.filter(~FuelStationCompany.ure_key.in_(incoming_keys)).update({FuelStationCompany.active: False}, synchronize_session=False)
|
||||
db.session.flush()
|
||||
return added, updated
|
||||
@@ -0,0 +1,5 @@
|
||||
<ul class="nav nav-tabs mb-4">
|
||||
<li class="nav-item"><a class="nav-link {% if request.endpoint=='main.admin_companies' %}active{% endif %}" href="{{url_for('main.admin_companies')}}">Firmy i karty</a></li>
|
||||
{% if current_user.role=='admin' %}<li class="nav-item"><a class="nav-link {% if request.endpoint=='main.admin_application' %}active{% endif %}" href="{{url_for('main.admin_application')}}">Aplikacja</a></li>{% endif %}
|
||||
<li class="nav-item"><a class="nav-link {% if request.endpoint=='main.admin_users' %}active{% endif %}" href="{{url_for('main.admin_users')}}">Użytkownicy</a></li>
|
||||
</ul>
|
||||
@@ -0,0 +1,17 @@
|
||||
{% extends 'base.html' %}{% block content %}
|
||||
<div class="mb-4"><h1 class="h2 mb-1">Administracja</h1><p class="text-body-secondary mb-0">Ustawienia firmy i zarządzanie użytkownikami.</p></div>
|
||||
<div class="row g-4"><div class="col-xl-6"><div class="card"><div class="card-body"><h2 class="h5">Ustawienia firmy</h2>
|
||||
<form method="post" action="/api/settings" data-method="PUT" class="row g-3 ajax-form">
|
||||
<div class="col-12"><label class="form-label">Nazwa firmy</label><input class="form-control" name="name" value="{{settings.name}}" required></div><div class="col-6"><label class="form-label">Typ firmy</label><select class="form-select" name="entity_type"><option {% if settings.entity_type=='JDG' %}selected{% endif %}>JDG</option><option>Spółka</option></select></div><div class="col-6"><label class="form-label">VAT %</label><input class="form-control" type="number" step="0.01" name="vat_rate" value="{{settings.vat_rate}}"></div>
|
||||
<div class="col-6"><label class="form-label">Odliczenie VAT %</label><input class="form-control" type="number" min="0" max="100" step="1" name="vat_deduction_percent" value="{{settings.vat_deduction_percent}}"></div>
|
||||
<div class="col-6"><label class="form-label">Dzień podziału</label><input class="form-control" type="number" min="1" max="27" name="invoice_split_day" value="{{settings.invoice_split_day}}"></div>
|
||||
<div class="col-12"><div class="form-check"><input class="form-check-input" type="checkbox" name="invoice_split_enabled" id="invoice-split" {% if split_enabled %}checked{% endif %}><label class="form-check-label" for="invoice-split">Dziel faktury kartowe według dnia miesiąca</label></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="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-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>
|
||||
{% 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>
|
||||
<div class="modal fade" id="userEditModal" tabindex="-1"><div class="modal-dialog"><div class="modal-content"><form method="post" class="ajax-form" id="user-edit-form" data-method="PUT"><div class="modal-header"><h2 class="modal-title fs-5">Edytuj użytkownika</h2><button type="button" class="btn-close" data-bs-dismiss="modal"></button></div><div class="modal-body"><div class="mb-3"><label class="form-label">Nazwa</label><input class="form-control" name="name" required></div><div class="mb-3"><label class="form-label">E-mail</label><input class="form-control" type="email" name="email" required></div><div class="mb-3"><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="mb-3"><label class="form-label">Domyślna karta paliwowa</label><select class="form-select" name="fuel_card_id"><option value="">Brak</option>{% for card in fuel_cards if card.active %}<option value="{{card.id}}">{{card.name}}</option>{% endfor %}</select></div><div class="mb-3"><label class="form-label">Nowe hasło</label><input class="form-control" type="password" name="password" minlength="8" placeholder="Pozostaw puste bez zmiany"></div><div class="form-check"><input class="form-check-input" type="checkbox" name="active" id="modal-active"><label class="form-check-label" for="modal-active">Konto aktywne</label></div></div><div class="modal-footer"><button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Anuluj</button><button class="btn btn-primary">Zapisz</button></div></form></div></div></div>{% endif %}
|
||||
{% endblock %}{% block scripts %}<script>FuelTrack.bindUserModal();</script>{% endblock %}
|
||||
@@ -0,0 +1,4 @@
|
||||
{% extends 'base.html' %}{% block title %}Ustawienia aplikacji{% endblock %}{% block content %}
|
||||
<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>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,63 @@
|
||||
{% extends 'base.html' %}
|
||||
{% block title %}Firmy i karty{% endblock %}
|
||||
{% block content %}
|
||||
<div class="d-flex flex-column flex-lg-row justify-content-between align-items-lg-center gap-3 mb-3">
|
||||
<div><h1 class="h2 mb-1">Administracja</h1><p class="text-body-secondary mb-0">Firmy, karty paliwowe i firmowe ulubione stacje.</p></div>
|
||||
{% if current_user.role == 'admin' %}<button class="btn btn-primary" data-bs-toggle="modal" data-bs-target="#createCompanyModal">Dodaj firmę</button>{% endif %}
|
||||
</div>
|
||||
{% include '_admin_nav.html' %}
|
||||
|
||||
<div class="row g-4">
|
||||
<div class="col-xl-4 col-xxl-3">
|
||||
<div class="card h-100">
|
||||
<div class="card-header"><strong>Firmy</strong></div>
|
||||
<div class="card-body border-bottom">
|
||||
<form method="get" action="{{ url_for('main.admin_companies') }}" class="ajax-nav-form">
|
||||
<div class="input-group"><input class="form-control" name="q" value="{{ q }}" placeholder="Szukaj firmy"><button class="btn btn-secondary">Szukaj</button></div>
|
||||
</form>
|
||||
</div>
|
||||
<div class="list-group list-group-flush">
|
||||
{% for c in companies_page.items %}
|
||||
<a href="{{ url_for('main.admin_companies', company_id=c.id, q=q, page=companies_page.page) }}" class="list-group-item list-group-item-action ajax-nav-link {% if selected_company and selected_company.id == c.id %}active{% endif %}">
|
||||
<div class="d-flex justify-content-between gap-2"><strong class="text-truncate">{{ c.name }}</strong><span class="badge {% if c.active %}text-bg-success{% else %}text-bg-secondary{% endif %}">{{ 'aktywna' if c.active else 'wyłączona' }}</span></div>
|
||||
<div class="small {% if selected_company and selected_company.id == c.id %}text-white-50{% else %}text-body-secondary{% endif %}">{{ c.entity_type }} · {{ c.region }} · {{ c.users|length }} użytk.</div>
|
||||
</a>
|
||||
{% else %}<div class="p-4 text-center text-body-secondary">Brak firm.</div>{% endfor %}
|
||||
</div>
|
||||
{% if companies_page.pages > 1 %}
|
||||
<div class="card-footer"><nav><ul class="pagination pagination-sm mb-0 justify-content-center">
|
||||
{% for p in companies_page.iter_pages() %}{% if p %}<li class="page-item {% if p == companies_page.page %}active{% endif %}"><a class="page-link ajax-nav-link" href="{{ url_for('main.admin_companies', page=p, q=q, company_id=selected_company.id if selected_company else none) }}">{{ p }}</a></li>{% else %}<li class="page-item disabled"><span class="page-link">…</span></li>{% endif %}{% endfor %}
|
||||
</ul></nav></div>{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-xl-8 col-xxl-9">
|
||||
{% if selected_company %}
|
||||
<div class="card mb-4"><div class="card-header d-flex justify-content-between align-items-center"><div><strong>{{ selected_company.name }}</strong><div class="small text-body-secondary">Ustawienia firmy</div></div><span class="badge text-bg-secondary">ID {{ selected_company.id }}</span></div>
|
||||
<div class="card-body">
|
||||
<form action="/api/companies/{{ selected_company.id }}" data-method="PUT" class="ajax-form row g-3">
|
||||
<div class="col-lg-5"><label class="form-label">Nazwa firmy</label><input class="form-control" name="name" value="{{ selected_company.name }}" required></div>
|
||||
<div class="col-md-4 col-lg-3"><label class="form-label">Forma prawna</label><select class="form-select" name="entity_type"><option {% if selected_company.entity_type=='JDG' %}selected{% endif %}>JDG</option><option {% if selected_company.entity_type=='Sp. z o.o.' %}selected{% endif %}>Sp. z o.o.</option><option {% if selected_company.entity_type=='Inna' %}selected{% endif %}>Inna</option></select></div>
|
||||
<div class="col-md-8 col-lg-4"><label class="form-label">Województwo</label><select class="form-select" name="region">{% for r in regions %}<option value="{{r}}" {% if selected_company.region==r %}selected{% endif %}>{{r}}</option>{% endfor %}</select></div>
|
||||
<div class="col-md-4"><label class="form-label">Stawka VAT (%)</label><input class="form-control" type="number" min="0" step="0.01" name="vat_rate" value="{{ selected_company.vat_rate }}"></div>
|
||||
<div class="col-md-4"><label class="form-label">Odliczenie VAT (%)</label><input class="form-control" type="number" min="0" max="100" step="0.01" name="vat_deduction_percent" value="{{ selected_company.vat_deduction_percent }}"></div>
|
||||
<div class="col-md-4"><label class="form-label">Podział faktury po dniu</label><input class="form-control" type="number" min="1" max="28" name="invoice_split_day" value="{{ selected_company.invoice_split_day }}"></div>
|
||||
<div class="col-12 d-flex justify-content-between align-items-center"><div class="form-check"><input class="form-check-input" name="active" type="checkbox" id="company-active" {% if selected_company.active %}checked{% endif %}><label class="form-check-label" for="company-active">Firma aktywna</label></div><button class="btn btn-primary">Zapisz ustawienia</button></div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card mb-4"><div class="card-header"><strong>Karty paliwowe</strong></div><div class="card-body">
|
||||
<form action="/api/fuel-cards" method="post" class="ajax-form row g-3 mb-4"><input type="hidden" name="company_id" value="{{selected_company.id}}"><div class="col-md-4"><label class="form-label">Nazwa karty</label><input class="form-control" name="name" placeholder="Np. UTA – flota główna" required></div><div class="col-md-3"><label class="form-label">Operator karty</label><input class="form-control" name="provider" placeholder="UTA, DKV, Orlen" required></div><div class="col-md-3"><label class="form-label">Opis</label><input class="form-control" name="description" placeholder="Opcjonalnie"></div><div class="col-md-2 d-flex align-items-end"><button class="btn btn-primary w-100">Dodaj kartę</button></div></form>
|
||||
<div class="table-responsive"><table class="table table-hover align-middle mb-0"><thead><tr><th>Karta</th><th>Operator karty</th><th>Opis</th><th>Status</th></tr></thead><tbody>{% for card in fuel_cards %}<tr><td><strong>{{card.name}}</strong></td><td>{{card.provider}}</td><td>{{card.description or '—'}}</td><td><span class="badge {% if card.active %}text-bg-success{% else %}text-bg-secondary{% endif %}">{{'aktywna' if card.active else 'wyłączona'}}</span></td></tr>{% else %}<tr><td colspan="4" class="text-center text-body-secondary py-4">Brak kart paliwowych.</td></tr>{% endfor %}</tbody></table></div>
|
||||
</div></div>
|
||||
|
||||
<div class="card"><div class="card-body d-flex flex-column flex-md-row justify-content-between align-items-md-center gap-3"><div><h2 class="h5 mb-1">Ulubione stacje firmy</h2><p class="text-body-secondary mb-0">Do 25 stacji. Pusta lista oznacza 10 największych sieci w Polsce.</p></div><button class="btn btn-secondary" data-bs-toggle="modal" data-bs-target="#companyFavoritesModal">Edytuj ulubione</button></div></div>
|
||||
|
||||
<div class="modal fade" id="companyFavoritesModal" tabindex="-1"><div class="modal-dialog modal-lg modal-dialog-scrollable"><div class="modal-content"><form action="/api/companies/{{selected_company.id}}/favorite-stations" data-method="PUT" class="ajax-form favorite-station-form" data-max-favorites="25"><div class="modal-header"><h2 class="modal-title fs-5">Ulubione stacje: {{selected_company.name}}</h2><button class="btn-close" data-bs-dismiss="modal"></button></div><div class="modal-body"><input class="form-control mb-3 station-modal-search" placeholder="Szukaj po nazwie sieci, firmy lub punktu"><div class="table-responsive"><table class="table table-hover align-middle"><thead><tr><th></th><th>Nazwa</th><th class="text-end">Punkty</th></tr></thead><tbody>{% for s in stations %}<tr data-station-name="{{(s.display_name ~ ' ' ~ s.company_name ~ ' ' ~ (s.station_name_list|join(' ')))|lower}}"><td><input class="form-check-input" type="checkbox" name="station_ids" value="{{s.id}}" {% if s in selected_company.favorite_stations %}checked{% endif %}></td><td><strong>{{s.display_name}}</strong>{% if s.company_name != s.display_name %}<div class="small text-body-secondary">{{s.company_name}}</div>{% endif %}</td><td class="text-end">{{s.station_count}}</td></tr>{% endfor %}</tbody></table></div></div><div class="modal-footer"><button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Anuluj</button><button class="btn btn-primary">Zapisz</button></div></form></div></div></div>
|
||||
{% else %}<div class="card"><div class="card-body text-center text-body-secondary py-5">Dodaj lub wybierz firmę.</div></div>{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if current_user.role == 'admin' %}<div class="modal fade" id="createCompanyModal" tabindex="-1"><div class="modal-dialog"><div class="modal-content"><form action="/api/companies" method="post" class="ajax-form"><div class="modal-header"><h2 class="modal-title fs-5">Dodaj firmę</h2><button class="btn-close" data-bs-dismiss="modal"></button></div><div class="modal-body"><div class="mb-3"><label class="form-label">Nazwa firmy</label><input class="form-control" name="name" required></div><div class="row g-3"><div class="col-md-6"><label class="form-label">Forma prawna</label><select class="form-select" name="entity_type"><option>JDG</option><option>Sp. z o.o.</option><option>Inna</option></select></div><div class="col-md-6"><label class="form-label">Województwo</label><select class="form-select" name="region">{% for r in regions %}<option value="{{r}}">{{r}}</option>{% endfor %}</select></div><div class="col-md-6"><label class="form-label">Stawka VAT (%)</label><input class="form-control" type="number" name="vat_rate" value="23" step="0.01"></div><div class="col-md-6"><label class="form-label">Odliczenie VAT (%)</label><input class="form-control" type="number" name="vat_deduction_percent" value="50" step="0.01"></div></div></div><div class="modal-footer"><button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Anuluj</button><button class="btn btn-primary">Dodaj firmę</button></div></form></div></div></div>{% endif %}
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,6 @@
|
||||
{% extends 'base.html' %}{% block title %}Użytkownicy{% endblock %}{% block content %}
|
||||
<h1 class="h2 mb-3">Administracja</h1>{% include '_admin_nav.html' %}
|
||||
<div class="card mb-4"><div class="card-body"><h2 class="h5">Dodaj użytkownika</h2><form action="/api/users" method="post" class="ajax-form row g-3"><div class="col-md-3"><input class="form-control" name="user_name" placeholder="Imię i nazwisko" required></div><div class="col-md-3"><input class="form-control" type="email" name="email" placeholder="E-mail" required></div><div class="col-md-2"><input class="form-control" type="password" name="password" minlength="8" placeholder="Hasło" required></div><div class="col-md-2"><select class="form-select" name="company_id">{% for c in companies %}<option value="{{c.id}}">{{c.name}}</option>{% endfor %}</select></div><div class="col-md-2"><select class="form-select" name="role"><option value="user">User</option><option value="boss">Szef</option>{% if current_user.role=='admin' %}<option value="admin">Admin</option>{% endif %}</select></div><div class="col-12"><button class="btn btn-primary">Dodaj użytkownika</button></div></form></div></div>
|
||||
<div class="card"><div class="card-body"><div class="d-flex justify-content-between gap-3 mb-3"><h2 class="h5 mb-0">Użytkownicy</h2><form class="d-flex ajax-nav-form" method="get"><input class="form-control" name="q" value="{{q}}" placeholder="Szukaj"><button class="btn btn-secondary ms-2">Szukaj</button></form></div><div class="table-responsive"><table class="table table-hover"><thead><tr><th>Nazwa</th><th>Firma</th><th>Rola</th><th>Karta</th><th></th></tr></thead><tbody>{% for u in users_page.items %}<tr><td>{{u.name}}<div class="small text-body-secondary">{{u.email}}</div></td><td>{{u.company.name if u.company else '—'}}</td><td>{{u.role}}</td><td>{{u.fuel_card.name if u.fuel_card else '—'}}</td><td class="text-end"><button class="btn btn-primary btn-sm 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 ''}}" data-company="{{u.company_id or ''}}">Edytuj</button></td></tr>{% endfor %}</tbody></table></div></div></div>
|
||||
<div class="modal fade" id="userEditModal"><div class="modal-dialog"><div class="modal-content"><form id="user-edit-form" class="ajax-form" data-method="PUT"><div class="modal-header"><h2 class="modal-title fs-5">Edytuj użytkownika</h2><button class="btn-close" data-bs-dismiss="modal"></button></div><div class="modal-body row g-3"><div class="col-12"><label class="form-label">Nazwa</label><input class="form-control" name="name" required></div><div class="col-12"><label class="form-label">E-mail</label><input class="form-control" name="email" type="email" required></div><div class="col-md-6"><label class="form-label">Firma</label><select class="form-select" name="company_id">{% for c in companies %}<option value="{{c.id}}">{{c.name}}</option>{% endfor %}</select></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>{% if current_user.role=='admin' %}<option value="admin">Admin</option>{% endif %}</select></div><div class="col-12"><label class="form-label">Karta paliwowa</label><select class="form-select" name="fuel_card_id"><option value="">Brak</option>{% for card in fuel_cards %}<option value="{{card.id}}">{{card.name}}</option>{% endfor %}</select></div><div class="col-12"><label class="form-label">Nowe hasło</label><input class="form-control" type="password" name="password" minlength="8"></div><div class="col-12 form-check ms-2"><input class="form-check-input" type="checkbox" name="active" id="modal-active"><label class="form-check-label" for="modal-active">Aktywny</label></div></div><div class="modal-footer"><button class="btn btn-danger me-auto" id="user-delete-btn" type="button">Usuń użytkownika</button><button class="btn btn-secondary" data-bs-dismiss="modal" type="button">Anuluj</button><button class="btn btn-primary">Zapisz</button></div></form></div></div></div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,33 @@
|
||||
<!doctype html>
|
||||
<html lang="pl" data-bs-theme="{{ app_theme.mode }}">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>{% block title %}FuelTrack{% endblock %}</title>
|
||||
<link id="bootstrap-theme" rel="stylesheet" href="{{ app_theme.url }}">
|
||||
<link rel="stylesheet" href="{{ asset_urls.choices_css }}">
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/layout.css') }}">
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/components.css') }}">
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/charts.css') }}">
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/choices.css') }}">
|
||||
</head>
|
||||
<body>
|
||||
<nav class="navbar navbar-expand-lg bg-body-tertiary border-bottom sticky-top"><div class="container"><a class="navbar-brand fw-bold text-body-emphasis" href="{{ url_for('main.dashboard') }}">FuelTrack</a>{% if current_user.is_authenticated %}<div class="d-flex gap-2 align-items-center flex-wrap"><a class="btn btn-sm btn-secondary" href="{{ url_for('main.dashboard') }}">Podsumowanie</a><a class="btn btn-sm btn-secondary" href="{{ url_for('main.fuel') }}">Tankowanie</a><a class="btn btn-sm btn-secondary" href="{{ url_for('main.vehicles') }}">Pojazdy</a><a class="btn btn-sm btn-secondary" href="{{ url_for('main.orlen_data') }}">Ceny Orlen</a>{% if current_user.role in ['boss','admin'] %}<a class="btn btn-sm btn-secondary" href="{{ url_for('main.stations') }}">Stacje</a><a class="btn btn-sm btn-secondary" href="{{ url_for('main.admin_companies') }}">Administracja</a>{% endif %}<a class="btn btn-sm btn-danger" href="/api/auth/logout" data-api-logout>Wyloguj</a></div>{% endif %}</div></nav>
|
||||
<main class="container py-4">{% with msgs=get_flashed_messages(with_categories=true) %}{% for cat,msg in msgs %}<div class="alert alert-{{cat}}">{{msg}}</div>{% endfor %}{% endwith %}{% block content %}{% endblock %}</main>
|
||||
<footer class="border-top mt-auto py-3"><div class="container d-flex justify-content-between align-items-center small text-body-secondary"><span>FuelTrack</span><a href="/docs" target="_blank" rel="noopener">API Docs</a></div></footer>
|
||||
<script src="{{ asset_urls.bootstrap_js }}"></script>
|
||||
<script src="{{ asset_urls.choices_js }}"></script>
|
||||
<script src="{{ asset_urls.chart_js }}"></script>
|
||||
<script src="{{ asset_urls.socket_io_js }}"></script>
|
||||
<script src="{{ url_for('static', filename='js/core.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='js/charts.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='js/ajax.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='js/stations.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='js/orlen.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='js/fuel.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='js/auth.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='js/users.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='js/socket.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='js/init.js') }}"></script>
|
||||
{% block scripts %}{% endblock %}
|
||||
</body></html>
|
||||
@@ -0,0 +1,6 @@
|
||||
{% 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="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="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="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>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><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="9">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 %}
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
|
||||
{% extends 'base.html' %}{% block title %}Logowanie – FuelTrack{% endblock %}{% block content %}<div class="row justify-content-center"><div class="col-md-5 col-lg-4"><div class="card shadow-sm"><div class="card-body p-4"><h1 class="h3 mb-3">Logowanie</h1><form method="post" action="/api/auth/login" class="api-login-form" autocomplete="on"><label class="form-label">E-mail</label><input class="form-control mb-3" type="email" name="email" autocomplete="username" required><label class="form-label">Hasło</label><input class="form-control mb-3" type="password" name="password" autocomplete="current-password" required><button class="btn btn-primary w-100">Zaloguj</button></form></div></div></div></div>{% endblock %}
|
||||
@@ -0,0 +1,8 @@
|
||||
{% extends 'base.html' %}{% block title %}Ceny Orlen{% endblock %}{% block content %}
|
||||
<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>
|
||||
<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>
|
||||
|
||||
<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>
|
||||
<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>
|
||||
{% endblock %}{% block scripts %}<script>FuelTrack.renderMultiLineChart('orlen-chart', {{chart|tojson}}, {{include_vat|tojson}});FuelTrack.bindOrlenSync();</script>{% endblock %}
|
||||
@@ -0,0 +1,24 @@
|
||||
{% extends 'base.html' %}
|
||||
{% block title %}Stacje paliw{% endblock %}
|
||||
{% block content %}
|
||||
<div class="d-flex flex-column flex-lg-row justify-content-between gap-3 align-items-lg-end mb-4">
|
||||
<div><h1 class="h2 mb-1">Predefiniowane stacje paliw</h1><p class="text-body-secondary mb-0">Firmy agregowane z rejestru URE. Warunki rozliczeń ustawiasz osobno dla każdej karty paliwowej.</p></div>
|
||||
<form method="post" action="/api/stations/sync" class="ajax-form"><button class="btn btn-primary">Pobierz i agreguj z URE</button></form>
|
||||
</div>
|
||||
{% if companies|length > 1 %}
|
||||
<div class="card mb-3"><div class="card-body"><form method="get" action="{{ url_for('main.stations') }}" class="row g-2 align-items-end ajax-nav-form"><div class="col-md-6"><label class="form-label">Konfigurowana firma</label><select class="form-select" name="company_id" onchange="this.form.requestSubmit()">{% for item in companies %}<option value="{{ item.id }}" {% if item.id == company.id %}selected{% endif %}>{{ item.name }}</option>{% endfor %}</select></div><input type="hidden" name="q" value="{{ q }}"><input type="hidden" name="sort" value="{{ sort }}"><input type="hidden" name="direction" value="{{ direction }}"></form></div></div>
|
||||
{% endif %}
|
||||
<div class="card mb-4"><div class="card-body"><form method="get" action="{{url_for('main.stations')}}" class="row g-2 ajax-nav-form" id="station-search-form"><input type="hidden" name="company_id" value="{{ company.id }}"><div class="col-lg-6"><input class="form-control" name="q" value="{{q}}" placeholder="Szukaj po nazwie stacji lub firmy" autocomplete="off" data-live-search></div><div class="col-md-3"><select class="form-select" name="sort" data-live-sort><option value="company_name" {% if sort=='company_name' %}selected{% endif %}>Nazwa</option><option value="station_count" {% if sort=='station_count' %}selected{% endif %}>Liczba stacji</option><option value="nip" {% if sort=='nip' %}selected{% endif %}>NIP</option><option value="regon" {% if sort=='regon' %}selected{% endif %}>REGON</option><option value="selected_region" {% if sort=='selected_region' %}selected{% endif %}>Województwo</option></select></div><div class="col-md-2"><select class="form-select" name="direction" data-live-sort><option value="asc" {% if direction=='asc' %}selected{% endif %}>Rosnąco</option><option value="desc" {% if direction=='desc' %}selected{% endif %}>Malejąco</option></select></div><div class="col-md-1"><button class="btn btn-secondary w-100">Szukaj</button></div></form></div></div>
|
||||
<div class="card"><div class="card-body"><div class="table-responsive"><table class="table table-hover align-middle"><thead><tr><th><a class="link-body-emphasis ajax-nav-link" href="{{url_for('main.stations',company_id=company.id,q=q,sort='company_name',direction='desc' if sort=='company_name' and direction=='asc' else 'asc')}}">Stacja / firma</a></th><th><a class="link-body-emphasis ajax-nav-link" href="{{url_for('main.stations',company_id=company.id,q=q,sort='station_count',direction='asc' if sort=='station_count' and direction=='desc' else 'desc')}}">Punkty</a></th><th>Paliwa</th><th>Ustawienia</th><th>Warunki kart</th></tr></thead><tbody>
|
||||
{% for s in station_page.items %}<tr>
|
||||
<td>{% if s.station_count > 1 %}<button type="button" class="btn btn-link link-body-emphasis fw-semibold p-0 text-start station-points-open" data-station-id="{{s.id}}" data-station-name="{{s.display_name}}">{{s.display_name}}</button>{% else %}<strong>{{s.display_name}}</strong>{% endif %}{% if s.company_name != s.display_name %}<div class="small text-body-secondary">{{s.company_name}}</div>{% endif %}<div class="small text-body-secondary">NIP: {{s.nip or '—'}} · REGON: {{s.regon or '—'}}</div></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><form method="post" action="/api/stations/{{s.id}}" data-method="PUT" class="ajax-form row g-2">{% if current_user.role == 'admin' %}<div class="col-12"><label class="form-label small mb-1">Firma</label><select class="form-select form-select-sm station-inline-company-select" name="company_id" data-station-context="1">{% for item in companies %}<option value="{{item.id}}" {% if item.id == company.id %}selected{% endif %}>{{item.name}}</option>{% endfor %}</select></div>{% else %}<input type="hidden" name="company_id" value="{{ company.id }}">{% endif %}<div class="col-12"><label class="form-label small mb-1">Województwo LPG</label><select class="form-select form-select-sm" name="selected_region"><option value="">Domyślne województwo firmy</option>{% for region in s.region_list %}<option value="{{region}}" {% if s.selected_region==region %}selected{% endif %}>{{region}}</option>{% endfor %}</select></div><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}}">Dozwolona dla firmy</label></div></div><div class="col-12 d-flex gap-2"><button class="btn btn-primary btn-sm">Zapisz</button><button type="button" class="btn btn-secondary btn-sm station-favorite-toggle" data-url="/api/companies/{{company.id}}/favorite-stations/{{s.id}}/toggle">{% if s.id in favorite_ids %}Usuń z ulubionych{% else %}Dodaj do ulubionych{% endif %}</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>
|
||||
</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>
|
||||
{% 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 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 %}
|
||||
@@ -0,0 +1,19 @@
|
||||
{% extends 'base.html' %}{% block content %}
|
||||
<h1 class="h2 mb-3">Pojazdy</h1>
|
||||
<div class="row g-4">
|
||||
<div class="col-lg-5"><div class="card"><div class="card-body"><h2 class="h5">Dodaj pojazd</h2>
|
||||
<form method="post" action="/api/vehicles" class="ajax-form row g-3">
|
||||
<div class="col-12"><input class="form-control" name="name" placeholder="Nazwa, np. Auto handlowca" required></div>
|
||||
<div class="col-6"><input class="form-control" name="make" placeholder="Marka" required></div><div class="col-6"><input class="form-control" name="model" placeholder="Model" required></div>
|
||||
<div class="col-6"><input class="form-control" name="registration" placeholder="Rejestracja" required></div><div class="col-6"><select class="form-select" name="fuel_type"><option>PB95</option><option>PB98</option><option>DIESEL</option><option>LPG</option></select></div>
|
||||
<div class="col-6"><input class="form-control" type="number" name="current_odometer" placeholder="Licznik"></div>{% if current_user.role in ['boss','admin'] %}<div class="col-12"><label class="form-label">Właściciel / użytkownik pojazdu</label><select class="form-select" name="owner_id">{% for u in assignable_users %}<option value="{{u.id}}" {% if u.id==current_user.id %}selected{% endif %}>{{u.name}} · {{u.company.name if u.company else 'bez firmy'}}</option>{% endfor %}</select></div>{% endif %}<div class="col-6"><select class="form-select" name="fuel_card_id"><option value="">Bez przypisanej karty</option>{% for card in fuel_cards %}<option value="{{card.id}}">{{card.name}} · {{card.provider}}</option>{% endfor %}</select></div>
|
||||
<div class="col-12"><button class="btn btn-primary">Dodaj</button></div></form></div></div></div>
|
||||
<div class="col-lg-7">
|
||||
{% for v in vehicles %}<div class="card mb-3"><div class="card-body">
|
||||
<div class="d-flex flex-wrap justify-content-between align-items-start gap-2"><div><h2 class="h5 mb-1">{{v.name}}</h2><div>{{v.make}} {{v.model}} · {{v.registration}}</div><small class="text-body-secondary">{{v.fuel_type}} · {{v.current_odometer}} km{% if v.fuel_card %} · karta {{v.fuel_card.name}}{% endif %}</small></div><span class="badge text-bg-secondary flex-shrink-0">Właściciel: {{v.owner.name}}</span></div>
|
||||
{% if current_user.role in ['boss','admin'] or v.owner_id==current_user.id %}
|
||||
<div class="mt-3"><h3 class="h6">Udostępnienia</h3><div class="d-flex flex-wrap gap-2 mb-2">{% for driver in v.drivers %}<form method="post" action="/api/vehicles/{{v.id}}/shares/{{driver.id}}" data-method="DELETE" class="ajax-form"><button class="btn btn-sm btn-secondary" title="Odbierz dostęp">{{driver.name}} ×</button></form>{% else %}<span class="text-body-secondary small">Nikomu nie udostępniono.</span>{% endfor %}</div>
|
||||
<form method="post" action="/api/vehicles/{{v.id}}/shares" class="input-group ajax-form"><input class="form-control" type="email" name="email" placeholder="E-mail użytkownika" required><button class="btn btn-secondary">Udostępnij</button></form></div><form action="/api/vehicles/{{v.id}}/fuel-card" data-method="PUT" class="ajax-form row g-2 mt-2"><div class="col-md-8"><label class="form-label">Karta paliwowa pojazdu</label><select class="form-select" name="fuel_card_id"><option value="">Brak</option>{% for card in fuel_cards %}<option value="{{card.id}}" {% if v.fuel_card_id==card.id %}selected{% endif %}>{{card.name}} · {{card.provider}}</option>{% endfor %}</select></div><div class="col-md-4 d-flex align-items-end"><button class="btn btn-primary w-100">Zapisz kartę</button></div></form>
|
||||
|
||||
{% endif %}</div></div>{% else %}<div class="alert alert-info">Brak pojazdów.</div>{% endfor %}</div></div>
|
||||
{% endblock %}
|
||||
Reference in New Issue
Block a user