From bd562b3da6687e325e0628091f542c2c59d985d1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Gruszczy=C5=84ski?= Date: Mon, 13 Jul 2026 13:19:26 +0200 Subject: [PATCH] first commit --- .dockerignore | 14 + .env.example | 33 +++ .gitignore | 16 ++ Dockerfile | 11 + README.md | 178 ++++++++++++ app/__init__.py | 99 +++++++ app/api.py | 414 +++++++++++++++++++++++++++ app/auth.py | 19 ++ app/config.py | 73 +++++ app/domain/costs.py | 14 + app/domain/invoices.py | 2 + app/extensions.py | 8 + app/integrations/http.py | 9 + app/integrations/orlen.py | 70 +++++ app/integrations/ure.py | 112 ++++++++ app/main.py | 393 +++++++++++++++++++++++++ app/models.py | 205 +++++++++++++ app/openapi.py | 46 +++ app/services.py | 8 + app/static/css/charts.css | 1 + app/static/css/choices.css | 2 + app/static/css/components.css | 1 + app/static/css/layout.css | 1 + app/static/js/ajax.js | 1 + app/static/js/auth.js | 1 + app/static/js/charts.js | 1 + app/static/js/core.js | 6 + app/static/js/fuel.js | 1 + app/static/js/init.js | 1 + app/static/js/orlen.js | 1 + app/static/js/socket.js | 1 + app/static/js/stations.js | 150 ++++++++++ app/static/js/users.js | 1 + app/station_catalog.py | 34 +++ app/templates/_admin_nav.html | 5 + app/templates/admin.html | 17 ++ app/templates/admin_application.html | 4 + app/templates/admin_companies.html | 63 ++++ app/templates/admin_users.html | 6 + app/templates/base.html | 33 +++ app/templates/dashboard.html | 6 + app/templates/fuel_form.html | 1 + app/templates/login.html | 1 + app/templates/orlen.html | 8 + app/templates/stations.html | 24 ++ app/templates/vehicles.html | 19 ++ docker-compose.yml | 12 + requirements.txt | 9 + tests/test_app.py | 84 ++++++ wsgi.py | 2 + 50 files changed, 2221 insertions(+) create mode 100644 .dockerignore create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 README.md create mode 100644 app/__init__.py create mode 100644 app/api.py create mode 100644 app/auth.py create mode 100644 app/config.py create mode 100644 app/domain/costs.py create mode 100644 app/domain/invoices.py create mode 100644 app/extensions.py create mode 100644 app/integrations/http.py create mode 100644 app/integrations/orlen.py create mode 100644 app/integrations/ure.py create mode 100644 app/main.py create mode 100644 app/models.py create mode 100644 app/openapi.py create mode 100644 app/services.py create mode 100644 app/static/css/charts.css create mode 100644 app/static/css/choices.css create mode 100644 app/static/css/components.css create mode 100644 app/static/css/layout.css create mode 100644 app/static/js/ajax.js create mode 100644 app/static/js/auth.js create mode 100644 app/static/js/charts.js create mode 100644 app/static/js/core.js create mode 100644 app/static/js/fuel.js create mode 100644 app/static/js/init.js create mode 100644 app/static/js/orlen.js create mode 100644 app/static/js/socket.js create mode 100644 app/static/js/stations.js create mode 100644 app/static/js/users.js create mode 100644 app/station_catalog.py create mode 100644 app/templates/_admin_nav.html create mode 100644 app/templates/admin.html create mode 100644 app/templates/admin_application.html create mode 100644 app/templates/admin_companies.html create mode 100644 app/templates/admin_users.html create mode 100644 app/templates/base.html create mode 100644 app/templates/dashboard.html create mode 100644 app/templates/fuel_form.html create mode 100644 app/templates/login.html create mode 100644 app/templates/orlen.html create mode 100644 app/templates/stations.html create mode 100644 app/templates/vehicles.html create mode 100644 docker-compose.yml create mode 100644 requirements.txt create mode 100644 tests/test_app.py create mode 100644 wsgi.py diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..172d17b --- /dev/null +++ b/.dockerignore @@ -0,0 +1,14 @@ +.git +.gitignore +.env +.venv +venv +__pycache__ +.pytest_cache +*.pyc +*.pyo +*.db +*.sqlite +*.sqlite3 +tests +README.md diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..fbcfa42 --- /dev/null +++ b/.env.example @@ -0,0 +1,33 @@ +APP_HOST_PORT=8000 +APP_PORT=8000 +SECRET_KEY=change-me-in-production +DATABASE_URL=sqlite:////data/fueltrack.db +ORLEN_TIMEOUT=15 +URE_TIMEOUT=45 +GUNICORN_WORKERS=1 +GUNICORN_THREADS=8 +GUNICORN_TIMEOUT=0 + +# Integracje HTTP +HTTP_TIMEOUT_SECONDS=30 +ORLEN_TIMEOUT_SECONDS=15 +URE_TIMEOUT_SECONDS=45 +ORLEN_WHOLESALE_URL=https://tool.orlen.pl/api/wholesalefuelprices/ByProduct +ORLEN_LPG_URL=https://tool.orlen.pl/api/autogasprices +ORLEN_LPG_BY_DATE_URL=https://tool.orlen.pl/api/autogasprices/ByDate +URE_STATIONS_URL=https://api.ure.gov.pl/api/InfrastructureFuelStation + +# Zewnętrzne zasoby interfejsu +BOOTSTRAP_JS_URL=https://cdn.jsdelivr.net/npm/bootstrap@5.3.7/dist/js/bootstrap.bundle.min.js +CHOICES_CSS_URL=https://cdn.jsdelivr.net/npm/choices.js@11.1.0/public/assets/styles/choices.min.css +CHOICES_JS_URL=https://cdn.jsdelivr.net/npm/choices.js@11.1.0/public/assets/scripts/choices.min.js +CHART_JS_URL=https://cdn.jsdelivr.net/npm/chart.js@4.5.0/dist/chart.umd.min.js +SOCKET_IO_JS_URL=https://cdn.socket.io/4.8.1/socket.io.min.js +THEME_BOOTSTRAP_URL=https://cdn.jsdelivr.net/npm/bootstrap@5.3.7/dist/css/bootstrap.min.css +THEME_FLATLY_URL=https://cdn.jsdelivr.net/npm/bootswatch@5.3.7/dist/flatly/bootstrap.min.css +THEME_DARKLY_URL=https://cdn.jsdelivr.net/npm/bootswatch@5.3.7/dist/darkly/bootstrap.min.css +THEME_PULSE_URL=https://cdn.jsdelivr.net/npm/bootswatch@5.3.7/dist/pulse/bootstrap.min.css + +# Mapowanie nazw przedsiębiorców URE na marki widoczne w aplikacji. +# Własne wartości podaj jako JSON w jednej linii. +STATION_BRAND_ALIASES_JSON={"ORLEN":"ORLEN","BP EUROPA":"BP","SHELL":"Shell","MOL POLSKA":"MOL","CIRCLE K":"Circle K","ANWIM":"MOYA","AMIC":"AMIC Energy","LOTOS":"LOTOS","AVIA":"AVIA","PIEPRZYK":"Pieprzyk"} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e764f3b --- /dev/null +++ b/.gitignore @@ -0,0 +1,16 @@ +.env +.venv/ +venv/ +__pycache__/ +*.py[cod] +.pytest_cache/ +.coverage +htmlcov/ +*.db +*.sqlite +*.sqlite3 +instance/ +.DS_Store +.idea/ +.vscode/ +*.zip \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..0703958 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,11 @@ +FROM python:3.14-alpine +ENV PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1 +WORKDIR /app +RUN apt-get update && apt-get install -y --no-install-recommends curl && rm -rf /var/lib/apt/lists/* +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt +COPY . . +RUN mkdir -p /data +EXPOSE 8000 +HEALTHCHECK --interval=30s --timeout=5s --retries=3 CMD sh -c 'curl -f http://localhost:${APP_PORT:-8000}/health || exit 1' +CMD ["sh", "-c", "gunicorn --worker-class gthread --workers ${GUNICORN_WORKERS:-1} --threads ${GUNICORN_THREADS:-8} --timeout ${GUNICORN_TIMEOUT:-0} --bind 0.0.0.0:${APP_PORT:-8000} wsgi:app"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..c767110 --- /dev/null +++ b/README.md @@ -0,0 +1,178 @@ +# FuelTrack + +Aplikacja Flask do monitorowania paliwa, kosztów, VAT i firmowych kart paliwowych. + +## Start w Dockerze + +```bash +docker compose up --build +``` + +Aplikacja: `http://localhost:8000` + +Pierwsze konto: + +- e-mail: `admin@example.com` +- hasło: `admin123!` + +Zmień hasło i `SECRET_KEY` przed wdrożeniem. + +## WebSocket i Gunicorn + +Projekt uruchamia jeden worker `gthread`, 8 wątków i wyłączony timeout dla długich połączeń Socket.IO: + +```bash +gunicorn --worker-class gthread --workers 1 --threads 8 --timeout 0 --bind 0.0.0.0:8000 wsgi:app +``` + +Nie uruchamiaj aplikacji przez samo `gunicorn wsgi:app`, ponieważ domyślny worker `sync` i timeout 30 sekund mogą przerywać WebSocket błędem `SystemExit: 1`. + +## Ceny Orlen + +Zakładka **Ceny Orlen** pozwala: + +- wybrać Pb95, Pb98, diesel lub LPG, +- pobrać dane za wskazany rok, +- zapisać lub zaktualizować dane w SQLite, +- wyświetlić wykres trendu i tabelę historyczną. + +Dla Pb95, Pb98 i diesla wartość API za m³ jest dzielona przez 1000. Endpoint LPG zwraca regionalny zestaw dostępny w chwili pobrania. + +## Motyw + +Motyw jest ustawiany globalnie przez szefa lub administratora w zakładce **Administracja**. Darkly ustawia także `data-bs-theme="dark"`, dzięki czemu tło, formularze, tabele, listy i wykresy używają ciemnej palety. + +## Testy + +```bash +python -m pytest -q +``` + + +## Wersja v5 + +- jawny endpoint zapisu ustawień administracyjnych, +- opcjonalny podział faktur kartowych po dniu miesiąca, +- lista udostępnień pojazdu i odbieranie dostępu, +- reguły cennika karty: Orlen last price, rabat procentowy i dopłata za litr, +- porównanie ceny detalicznej z kwotą do zapłaty na wykresie i w tabeli. + +## REST API i Swagger + +Interfejs zapisuje i modyfikuje dane wyłącznie przez endpointy `/api/...`. Widoki HTML odpowiadają za prezentację, natomiast operacje biznesowe przechodzą przez REST API i zwracają JSON. + +Dokumentacja po uruchomieniu: + +- Swagger UI: `http://localhost:8000/docs` +- OpenAPI JSON: `http://localhost:8000/openapi.json` + +Autoryzacja API korzysta z tej samej bezpiecznej sesji cookie co interfejs. Najważniejsze grupy endpointów: + +- `/api/auth/*` +- `/api/users` +- `/api/settings` +- `/api/vehicles` +- `/api/fuel-entries` +- `/api/orlen/*` +- `/api/stations/*` + +Stare endpointy formularzy pozostają chwilowo jako warstwa zgodności dla zakładek HTML, ale interfejs nie wysyła już do nich operacji zapisu. + + +## Wersja v8 + +- odnośnik do Swagger UI znajduje się w stopce, +- specyfikacja OpenAPI nie deklaruje zbędnej sekcji `servers`, +- województwo firmy wybierane jest z listy 16 województw, +- wybór wielu województw LPG jest domyślnie ukryty; każda wybrana pozycja tworzy osobną serię, +- wyszukiwanie stacji działa na żywo przez AJAX i obejmuje firmę, marki, NIP oraz REGON, +- sortowanie katalogu po nazwie, liczbie punktów, NIP, REGON i województwie, +- last price działa wyłącznie według wybranej stacji i użycia karty paliwowej, +- maksymalnie 10 ulubionych sieci użytkownika; domyślnie wybierane są największe sieci, +- lista sieci dozwolonych przez firmę jest egzekwowana również przez API, +- konfiguracja portów, bazy, timeoutów i Gunicorna znajduje się w `.env`, +- dodane `.gitignore` oraz `.dockerignore`. + +Skopiuj i dostosuj konfigurację: + +```bash +cp .env.example .env +docker compose up --build +``` + +## Wersja 9 + +- motyw przeniesiony do ustawień aplikacji, +- firmowy katalog kart paliwowych, +- karta przypisywana użytkownikowi lub pojazdowi, +- warunki rozliczenia definiowane per karta i stacja, +- blokada stacji, Orlen last price, rabat netto i dopłata netto za litr, +- wyszukiwanie stacji po nazwie firmy lub marki stacji. + +## Administracja v10 + +Panel jest podzielony na: + +- `/admin/companies` — firmy, ustawienia podatkowe, karty paliwowe i firmowe ulubione stacje, +- `/admin/application` — globalny motyw aplikacji, +- `/admin/users` — użytkownicy, firma, rola, karta i hasło. + +Ulubione stacje są rozstrzygane kolejno: lista użytkownika, lista firmy, a następnie 10 największych sieci z katalogu URE. + +## Ograniczenie historii LPG Orlen + +Endpoint `https://tool.orlen.pl/api/autogasprices` zwraca wyłącznie bieżący snapshot cen dla 16 województw. Nie obsługuje parametrów daty. FuelTrack zapisuje każdy pobrany snapshot w bazie, dzięki czemu historia LPG narasta od momentu uruchomienia synchronizacji. Nie jest możliwe pobranie pełnej historii LPG wstecz z tego endpointu. + +## Konfiguracja integracji + +Adresy API Orlen/URE, timeouty, adresy bibliotek frontendowych i mapowanie nazw marek są konfigurowane w `.env`. Domyślne wartości znajdują się w `.env.example`. + +Kod integracji został rozdzielony na: + +- `app/integrations/orlen.py` – ceny Orlen, +- `app/integrations/ure.py` – pobieranie i agregacja rejestru URE, +- `app/integrations/http.py` – wspólna obsługa HTTP, +- `app/domain/costs.py` – obliczenia kosztów, +- `app/domain/invoices.py` – okresy faktur. + +Frontend jest podzielony funkcjonalnie w `app/static/js/` i `app/static/css/`. + + +## Punkty stacji URE +Po imporcie katalogu URE kliknięcie nazwy firmy mającej więcej niż jeden punkt otwiera modal z listą placówek, adresami, województwem i dostępnymi paliwami. + +## Poprawka importu punktów URE + +Importer scala powtarzające się rekordy tego samego punktu po numerze DKN, aktualizuje istniejące punkty zamiast usuwać i dodawać je ponownie oraz usuwa punkty, których nie ma już w aktualnym imporcie. Zapobiega to błędowi unikalności `fuel_station_point.station_company_id, fuel_station_point.ure_dkn`. + +## v12.2 +- poprawione agregowanie sieci stacji po marce i adresie punktu +- poprawione wyszukiwanie live i sortowanie +- DKN traktowany jako identyfikator źródłowy, nie unikalny punkt + + +## Reset hasła administratora w Docker Compose + +Bezpieczny wariant interaktywny (hasło nie jest wyświetlane): + +```bash +docker compose exec web flask reset-admin-password --email admin@example.com +``` + +Polecenie poprosi dwukrotnie o nowe hasło. Konto musi już istnieć i mieć rolę `admin`. + +Wariant nieinteraktywny, przydatny w skryptach (hasło może zostać zapisane w historii powłoki): + +```bash +docker compose exec web flask reset-admin-password \ + --email admin@example.com \ + --password 'NoweBezpieczneHaslo123!' +``` + +Usunięcie użytkownika jest dostępne w panelu administracyjnym. System nie pozwala usunąć własnego konta, ostatniego aktywnego administratora ani użytkownika posiadającego pojazdy lub historię tankowań. Takie konto można dezaktywować po edycji. + +## Zarządzanie wieloma firmami + +Panel **Administracja → Firmy i karty** używa listy firm z wyszukiwaniem i paginacją. Po wybraniu firmy po prawej stronie wyświetlane są tylko jej ustawienia, karty paliwowe i ulubione stacje. Dzięki temu liczba formularzy w DOM nie rośnie wraz z liczbą firm. + +W **Predefiniowanych stacjach paliw** administrator może wskazać firmę bezpośrednio w ustawieniach każdej pozycji. Zmiana firmy przeładowuje kontekst tabeli i pokazuje właściwe ulubione, dozwolone stacje oraz warunki kart. diff --git a/app/__init__.py b/app/__init__.py new file mode 100644 index 0000000..feb0362 --- /dev/null +++ b/app/__init__.py @@ -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 diff --git a/app/api.py b/app/api.py new file mode 100644 index 0000000..2c081c1 --- /dev/null +++ b/app/api.py @@ -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//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//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//shares/') +@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//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/') +@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/') +@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/') +@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//stations/') +@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//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/') +@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/') +@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//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//favorite-stations//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') diff --git a/app/auth.py b/app/auth.py new file mode 100644 index 0000000..84496b4 --- /dev/null +++ b/app/auth.py @@ -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")) diff --git a/app/config.py b/app/config.py new file mode 100644 index 0000000..85aa673 --- /dev/null +++ b/app/config.py @@ -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", + }) diff --git a/app/domain/costs.py b/app/domain/costs.py new file mode 100644 index 0000000..93c2b43 --- /dev/null +++ b/app/domain/costs.py @@ -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)} diff --git a/app/domain/invoices.py b/app/domain/invoices.py new file mode 100644 index 0000000..9f9719d --- /dev/null +++ b/app/domain/invoices.py @@ -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" diff --git a/app/extensions.py b/app/extensions.py new file mode 100644 index 0000000..0117e8e --- /dev/null +++ b/app/extensions.py @@ -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) diff --git a/app/integrations/http.py b/app/integrations/http.py new file mode 100644 index 0000000..8087358 --- /dev/null +++ b/app/integrations/http.py @@ -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() diff --git a/app/integrations/orlen.py b/app/integrations/orlen.py new file mode 100644 index 0000000..2bd5ff0 --- /dev/null +++ b/app/integrations/orlen.py @@ -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"]} diff --git a/app/integrations/ure.py b/app/integrations/ure.py new file mode 100644 index 0000000..6a31248 --- /dev/null +++ b/app/integrations/ure.py @@ -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())) diff --git a/app/main.py b/app/main.py new file mode 100644 index 0000000..82e9c4a --- /dev/null +++ b/app/main.py @@ -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//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//unshare/") +@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//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/") +@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/") +@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]) diff --git a/app/models.py b/app/models.py new file mode 100644 index 0000000..6c0bcdb --- /dev/null +++ b/app/models.py @@ -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")) diff --git a/app/openapi.py b/app/openapi.py new file mode 100644 index 0000000..e508548 --- /dev/null +++ b/app/openapi.py @@ -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("""FuelTrack API Docs
""", mimetype='text/html') diff --git a/app/services.py b/app/services.py new file mode 100644 index 0000000..d40a6dd --- /dev/null +++ b/app/services.py @@ -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"] diff --git a/app/static/css/charts.css b/app/static/css/charts.css new file mode 100644 index 0000000..74ca09b --- /dev/null +++ b/app/static/css/charts.css @@ -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)} diff --git a/app/static/css/choices.css b/app/static/css/choices.css new file mode 100644 index 0000000..1fb00d2 --- /dev/null +++ b/app/static/css/choices.css @@ -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)} diff --git a/app/static/css/components.css b/app/static/css/components.css new file mode 100644 index 0000000..9a14abe --- /dev/null +++ b/app/static/css/components.css @@ -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)} diff --git a/app/static/css/layout.css b/app/static/css/layout.css new file mode 100644 index 0000000..265d625 --- /dev/null +++ b/app/static/css/layout.css @@ -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} diff --git a/app/static/js/ajax.js b/app/static/js/ajax.js new file mode 100644 index 0000000..5398b2e --- /dev/null +++ b/app/static/js/ajax.js @@ -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'))})})}})(); diff --git a/app/static/js/auth.js b/app/static/js/auth.js new file mode 100644 index 0000000..1a1b3b8 --- /dev/null +++ b/app/static/js/auth.js @@ -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'})})}})(); diff --git a/app/static/js/charts.js b/app/static/js/charts.js new file mode 100644 index 0000000..92deea7 --- /dev/null +++ b/app/static/js/charts.js @@ -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()}}}}})}})(); diff --git a/app/static/js/core.js b/app/static/js/core.js new file mode 100644 index 0000000..ce1a75c --- /dev/null +++ b/app/static/js/core.js @@ -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)}}; diff --git a/app/static/js/fuel.js b/app/static/js/fuel.js new file mode 100644 index 0000000..bbf0720 --- /dev/null +++ b/app/static/js/fuel.js @@ -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()}})(); diff --git a/app/static/js/init.js b/app/static/js/init.js new file mode 100644 index 0000000..da0d151 --- /dev/null +++ b/app/static/js/init.js @@ -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()}); diff --git a/app/static/js/orlen.js b/app/static/js/orlen.js new file mode 100644 index 0000000..422d21c --- /dev/null +++ b/app/static/js/orlen.js @@ -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}})}})(); diff --git a/app/static/js/socket.js b/app/static/js/socket.js new file mode 100644 index 0000000..8bfc202 --- /dev/null +++ b/app/static/js/socket.js @@ -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'))}; diff --git a/app/static/js/stations.js b/app/static/js/stations.js new file mode 100644 index 0000000..efdfb8e --- /dev/null +++ b/app/static/js/stations.js @@ -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='Brak pasujących stacji.'; + 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=>`${esc(x.name)}${x.dkn?`
DKN: ${esc(x.dkn)}
`:''}${esc(x.address)}${esc(x.region||'—')}${x.has_petrol?'benzyna':''}${x.has_diesel?'diesel':''}${x.has_lpg?'LPG':''}`).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')); + }); + }); +}; +})(); diff --git a/app/static/js/users.js b/app/static/js/users.js new file mode 100644 index 0000000..6fad0b6 --- /dev/null +++ b/app/static/js/users.js @@ -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}})}}})(); diff --git a/app/station_catalog.py b/app/station_catalog.py new file mode 100644 index 0000000..6e19a42 --- /dev/null +++ b/app/station_catalog.py @@ -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 diff --git a/app/templates/_admin_nav.html b/app/templates/_admin_nav.html new file mode 100644 index 0000000..12fded9 --- /dev/null +++ b/app/templates/_admin_nav.html @@ -0,0 +1,5 @@ + diff --git a/app/templates/admin.html b/app/templates/admin.html new file mode 100644 index 0000000..de17245 --- /dev/null +++ b/app/templates/admin.html @@ -0,0 +1,17 @@ +{% extends 'base.html' %}{% block content %} +

Administracja

Ustawienia firmy i zarządzanie użytkownikami.

+

Ustawienia firmy

+
+
+
+
+
+
+

Dodaj użytkownika

+
+

Ustawienia aplikacji

+

Karty paliwowe firmy

{% for card in fuel_cards %}
{{card.name}}
{{card.provider}}{% if card.description %} · {{card.description}}{% endif %}
{{'aktywna' if card.active else 'nieaktywna'}}
{% else %}
Brak kart paliwowych.
{% endfor %}
+
+{% if current_user.role=='admin' %}

Użytkownicy

{{ users_page.total }} kont
{% for u in users_page.items %}{% else %}{% endfor %}
NazwaE-mailRolaStatusAkcja
{{u.name}}{{u.email}}{{u.role}}{% if u.active %}Aktywny{% else %}Nieaktywny{% endif %}
Brak użytkowników.
{% if users_page.pages > 1 %}{% endif %}
+{% endif %} +{% endblock %}{% block scripts %}{% endblock %} diff --git a/app/templates/admin_application.html b/app/templates/admin_application.html new file mode 100644 index 0000000..8f6413a --- /dev/null +++ b/app/templates/admin_application.html @@ -0,0 +1,4 @@ +{% extends 'base.html' %}{% block title %}Ustawienia aplikacji{% endblock %}{% block content %} +

Administracja

{% include '_admin_nav.html' %} +

Wygląd aplikacji

+{% endblock %} diff --git a/app/templates/admin_companies.html b/app/templates/admin_companies.html new file mode 100644 index 0000000..206ef3a --- /dev/null +++ b/app/templates/admin_companies.html @@ -0,0 +1,63 @@ +{% extends 'base.html' %} +{% block title %}Firmy i karty{% endblock %} +{% block content %} +
+

Administracja

Firmy, karty paliwowe i firmowe ulubione stacje.

+ {% if current_user.role == 'admin' %}{% endif %} +
+{% include '_admin_nav.html' %} + +
+
+
+
Firmy
+
+
+
+
+
+ + {% if companies_page.pages > 1 %} + {% endif %} +
+
+ +
+ {% if selected_company %} +
{{ selected_company.name }}
Ustawienia firmy
ID {{ selected_company.id }}
+
+
+
+
+
+
+
+
+
+
+
+
+ +
Karty paliwowe
+
+
{% for card in fuel_cards %}{% else %}{% endfor %}
KartaOperator kartyOpisStatus
{{card.name}}{{card.provider}}{{card.description or '—'}}{{'aktywna' if card.active else 'wyłączona'}}
Brak kart paliwowych.
+
+ +

Ulubione stacje firmy

Do 25 stacji. Pusta lista oznacza 10 największych sieci w Polsce.

+ + + {% else %}
Dodaj lub wybierz firmę.
{% endif %} +
+
+ +{% if current_user.role == 'admin' %}{% endif %} +{% endblock %} diff --git a/app/templates/admin_users.html b/app/templates/admin_users.html new file mode 100644 index 0000000..9e75e1d --- /dev/null +++ b/app/templates/admin_users.html @@ -0,0 +1,6 @@ +{% extends 'base.html' %}{% block title %}Użytkownicy{% endblock %}{% block content %} +

Administracja

{% include '_admin_nav.html' %} +

Dodaj użytkownika

+

Użytkownicy

{% for u in users_page.items %}{% endfor %}
NazwaFirmaRolaKarta
{{u.name}}
{{u.email}}
{{u.company.name if u.company else '—'}}{{u.role}}{{u.fuel_card.name if u.fuel_card else '—'}}
+ +{% endblock %} diff --git a/app/templates/base.html b/app/templates/base.html new file mode 100644 index 0000000..b7f27de --- /dev/null +++ b/app/templates/base.html @@ -0,0 +1,33 @@ + + + + + + {% block title %}FuelTrack{% endblock %} + + + + + + + + + +
{% with msgs=get_flashed_messages(with_categories=true) %}{% for cat,msg in msgs %}
{{msg}}
{% endfor %}{% endwith %}{% block content %}{% endblock %}
+ + + + + + + + + + + + + + + +{% block scripts %}{% endblock %} + diff --git a/app/templates/dashboard.html b/app/templates/dashboard.html new file mode 100644 index 0000000..1d22f8d --- /dev/null +++ b/app/templates/dashboard.html @@ -0,0 +1,6 @@ +{% extends 'base.html' %}{% block content %} +

Podsumowanie kosztów

{{ settings.name }} · VAT do odliczenia {{ settings.vat_deduction_percent }}%
+
Brutto{{ '%.2f'|format(totals.gross) }} zł
VAT odliczony{{ '%.2f'|format(totals.deductible_vat) }} zł
Koszt po VAT{{ '%.2f'|format(totals.final_cost) }} zł
Litry{{ '%.2f'|format(totals.liters) }} l
+

Koszt według pojazdu

Cena na dystrybutorze a kwota do zapłaty według cennika karty.

Brak danych dla wybranego miesiąca.

Okresy faktur

{% if split_enabled %}Podział według dnia {{settings.invoice_split_day}}{% else %}Podział dzienny wyłączony{% endif %}{% for key,row in invoices.items() %}{% else %}{% endfor %}
OkresPozycjeDetalDo zapłaty
{{key}}{{row.count}}{{'%.2f'|format(row.gross)}} zł{{'%.2f'|format(row.payable)}} zł
Brak danych
+

Tankowania

{% for e in entries %}{% set s=settlements[e.id] %}{% else %}{% endfor %}
DataAutoStacjaPaliwoLitryCena detal.Orlen lastDo zapłatyRazem
{{e.fueled_at.strftime('%d.%m.%Y')}}{{e.vehicle.name}}{{e.station or '—'}}{{e.fuel_type}}{{e.liters}}{{'%.4f'|format(s.normal_price)}} zł/l{% if e.wholesale_price %}{{'%.4f'|format(e.wholesale_price|float)}} zł/l{% else %}—{% endif %}{% if s.uses_last_price %}last price {% endif %}{{'%.4f'|format(s.payable_price)}} zł/l{{'%.2f'|format(s.normal_gross)}} zł
{{'%.2f'|format(s.payable_gross)}} zł
Brak tankowań w tym miesiącu.
+{% endblock %}{% block scripts %}{% endblock %} diff --git a/app/templates/fuel_form.html b/app/templates/fuel_form.html new file mode 100644 index 0000000..894452d --- /dev/null +++ b/app/templates/fuel_form.html @@ -0,0 +1 @@ +{% extends 'base.html' %}{% block content %}

Nowe tankowanie

Najpierw pokazujemy maksymalnie 10 ulubionych sieci. {% if restricted %} · firma ograniczyła listę do dozwolonych sieci{% endif %}.
Cena Orlen zostanie pobrana automatycznie.
{% endblock %}{% block scripts %}{% endblock %} diff --git a/app/templates/login.html b/app/templates/login.html new file mode 100644 index 0000000..424a87d --- /dev/null +++ b/app/templates/login.html @@ -0,0 +1 @@ +{% extends 'base.html' %}{% block title %}Logowanie – FuelTrack{% endblock %}{% block content %}

Logowanie

{% endblock %} diff --git a/app/templates/orlen.html b/app/templates/orlen.html new file mode 100644 index 0000000..e26f312 --- /dev/null +++ b/app/templates/orlen.html @@ -0,0 +1,8 @@ +{% extends 'base.html' %}{% block title %}Ceny Orlen{% endblock %}{% block content %} +

Dane cenowe Orlen

Porównuj kilka paliw na jednym wykresie i zapisuj dane w bazie.

+
{% for f in fuel_types %}{% endfor %}
{% if selected_regions %}{{selected_regions|length}} wybrano{% else %}Domyślne województwo{% endif %}
{% if current_user.role in ['boss','admin'] %}
{% endif %}
+ + +

Trend ceny {% if include_vat %}brutto{% else %}netto{% endif %} za litr

Brak danych dla wybranych filtrów.
+
{% for r in rows|reverse %}{% else %}{% endfor %}
DataPaliwoCena netto zł/lRegionPobrano
{{r.effective_date}}{{r.product_name}}{{'%.4f'|format(r.price_per_liter|float)}}{{r.region or '—'}}{{r.fetched_at.strftime('%Y-%m-%d %H:%M')}}
Brak danych w bazie.
+{% endblock %}{% block scripts %}{% endblock %} diff --git a/app/templates/stations.html b/app/templates/stations.html new file mode 100644 index 0000000..34fe5ec --- /dev/null +++ b/app/templates/stations.html @@ -0,0 +1,24 @@ +{% extends 'base.html' %} +{% block title %}Stacje paliw{% endblock %} +{% block content %} +
+

Predefiniowane stacje paliw

Firmy agregowane z rejestru URE. Warunki rozliczeń ustawiasz osobno dla każdej karty paliwowej.

+
+
+{% if companies|length > 1 %} +
+{% endif %} +
+
+{% for s in station_page.items %} + + + + + +{% else %}{% endfor %} +
Stacja / firmaPunktyPaliwaUstawieniaWarunki kart
{% if s.station_count > 1 %}{% else %}{{s.display_name}}{% endif %}{% if s.company_name != s.display_name %}
{{s.company_name}}
{% endif %}
NIP: {{s.nip or '—'}} · REGON: {{s.regon or '—'}}
{{s.station_count}}
{% if s.has_petrol %}benzyna{% endif %}{% if s.has_diesel %}diesel{% endif %}{% if s.has_lpg %}LPG{% endif %}
{% if current_user.role == 'admin' %}
{% else %}{% endif %}
{% for card in fuel_cards %}{% set rule=card_rules.get((card.id,s.id)) %}
{{card.name}}
{% else %}Najpierw dodaj kartę w ustawieniach firmy.{% endfor %}
Brak firm. Pobierz katalog z URE.
+{% if station_page.pages > 1 %}{% endif %} +
+ +{% endblock %} diff --git a/app/templates/vehicles.html b/app/templates/vehicles.html new file mode 100644 index 0000000..766c18a --- /dev/null +++ b/app/templates/vehicles.html @@ -0,0 +1,19 @@ +{% extends 'base.html' %}{% block content %} +

Pojazdy

+
+

Dodaj pojazd

+
+
+
+
+
{% if current_user.role in ['boss','admin'] %}
{% endif %}
+
+
+{% for v in vehicles %}
+

{{v.name}}

{{v.make}} {{v.model}} · {{v.registration}}
{{v.fuel_type}} · {{v.current_odometer}} km{% if v.fuel_card %} · karta {{v.fuel_card.name}}{% endif %}
Właściciel: {{v.owner.name}}
+{% if current_user.role in ['boss','admin'] or v.owner_id==current_user.id %} +

Udostępnienia

{% for driver in v.drivers %}
{% else %}Nikomu nie udostępniono.{% endfor %}
+
+ +{% endif %}
{% else %}
Brak pojazdów.
{% endfor %}
+{% endblock %} diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..3fab211 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,12 @@ +services: + web: + build: . + env_file: + - .env + ports: + - "${APP_HOST_PORT:-8000}:${APP_PORT:-8000}" + restart: unless-stopped + volumes: + - .:/app + - ./instance:/app/instance + - ./data:/app/data \ No newline at end of file diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..be34a03 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,9 @@ +Flask==3.1.1 +Flask-SQLAlchemy==3.1.1 +Flask-Login==0.6.3 +Flask-SocketIO==5.5.1 +simple-websocket==1.1.0 +requests==2.32.4 +gunicorn==23.0.0 +pytest==8.4.1 + diff --git a/tests/test_app.py b/tests/test_app.py new file mode 100644 index 0000000..b780706 --- /dev/null +++ b/tests/test_app.py @@ -0,0 +1,84 @@ +from app import create_app +from app.services import calculate_costs, invoice_period +from datetime import datetime + +def test_health(tmp_path): + app=create_app({"TESTING":True,"SQLALCHEMY_DATABASE_URI":f"sqlite:///{tmp_path/'test.db'}"}) + assert app.test_client().get('/health').json=={"status":"ok"} + +def test_costs_50_percent_vat(): + result=calculate_costs(123,23,50) + assert float(result['net'])==100.0 + assert float(result['deductible_vat'])==11.5 + assert float(result['final_cost'])==111.5 + +def test_invoice_period(): + assert invoice_period(datetime(2026,7,15),15).endswith('01–15') + assert invoice_period(datetime(2026,7,16),15).endswith('16–koniec') + +def test_admin_settings_ajax_has_working_route(tmp_path): + app=create_app({"TESTING":True,"SQLALCHEMY_DATABASE_URI":f"sqlite:///{tmp_path/'admin.db'}"}) + client=app.test_client() + client.post('/login',data={'email':'admin@example.com','password':'admin123!'}) + response=client.post('/admin',data={'action':'settings','name':'Firma','entity_type':'JDG','vat_rate':'23','vat_deduction_percent':'50','invoice_split_day':'15','invoice_split_enabled':'on','region':'mazowieckie','theme':'darkly'},headers={'X-Requested-With':'XMLHttpRequest'}) + assert response.status_code==200 + assert response.json['ok'] is True + +def test_ure_brand_aggregation(tmp_path): + from app.integrations.ure import aggregate_ure_companies + app=create_app({"TESTING":True,"SQLALCHEMY_DATABASE_URI":f"sqlite:///{tmp_path/'ure.db'}"}) + with app.app_context(): + rows=[{"nazwa":"ORLEN S.A.","nip":"7740001454","regon":"610188201","nazwaStacji":"100","wojewodztwo":"mazowieckie","benzynySilnikowe":True}, + {"nazwa":"ORLEN S.A.","nip":"7740001454","regon":"610188201","nazwaStacji":"101","wojewodztwo":"śląskie","olejeNapedowe":True}] + result=aggregate_ure_companies(rows) + assert len(result)==1 + assert result[0]["brand_name"]=="ORLEN" + assert result[0]["station_count"]==2 + +def test_ure_duplicate_dkn_is_merged(tmp_path): + from app.integrations.ure import aggregate_ure_companies + app=create_app({"TESTING":True,"SQLALCHEMY_DATABASE_URI":f"sqlite:///{tmp_path/'dup.db'}"}) + rows=[ + {"dkn":977,"nazwa":"Test Sp. z o.o.","nip":"123","nazwaStacji":"Punkt paliw","infraUlica":"A","infraNrLokalu":"1","infraPoczta":"Miasto","benzynySilnikowe":True}, + {"dkn":977,"nazwa":"Test Sp. z o.o.","nip":"123","nazwaStacji":"Punkt paliw i LPG","infraUlica":"A","infraNrLokalu":"1","infraPoczta":"Miasto","gazPlynnyLPG":True}, + ] + with app.app_context(): + result=aggregate_ure_companies(rows) + assert len(result)==1 + assert result[0]["station_count"]==1 + assert len(result[0]["points"])==1 + assert result[0]["points"][0]["has_petrol"] is True + assert result[0]["points"][0]["has_lpg"] is True + +def test_company_and_card_forms_use_post(tmp_path): + app=create_app({"TESTING":True,"SQLALCHEMY_DATABASE_URI":f"sqlite:///{tmp_path/'forms.db'}"}) + client=app.test_client() + client.post('/login',data={'email':'admin@example.com','password':'admin123!'}) + html=client.get('/admin/companies').get_data(as_text=True) + assert 'action="/api/companies" method="post"' in html + assert 'action="/api/fuel-cards" method="post"' in html + assert 'Nazwa firmy' in html + assert 'Operator karty' in html + + +def test_cannot_delete_last_admin(tmp_path): + app=create_app({"TESTING":True,"SQLALCHEMY_DATABASE_URI":f"sqlite:///{tmp_path/'delete.db'}"}) + client=app.test_client() + client.post('/api/auth/login',data={'email':'admin@example.com','password':'admin123!'}) + with app.app_context(): + from app.models import User + admin=User.query.filter_by(email='admin@example.com').first() + admin_id=admin.id + response=client.delete(f'/api/users/{admin_id}') + assert response.status_code == 400 + assert response.json['ok'] is False + + +def test_cli_reset_admin_password(tmp_path): + app=create_app({"TESTING":True,"SQLALCHEMY_DATABASE_URI":f"sqlite:///{tmp_path/'cli.db'}"}) + runner=app.test_cli_runner() + result=runner.invoke(args=['reset-admin-password','--email','admin@example.com','--password','NoweHaslo123!']) + assert result.exit_code == 0 + client=app.test_client() + response=client.post('/api/auth/login',data={'email':'admin@example.com','password':'NoweHaslo123!'}) + assert response.status_code == 200 diff --git a/wsgi.py b/wsgi.py new file mode 100644 index 0000000..4709dd7 --- /dev/null +++ b/wsgi.py @@ -0,0 +1,2 @@ +from app import create_app +app = create_app()