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} top_stations=sorted((x for x in stations if x.id not in favorite_ids),key=lambda x:(-x.station_count,x.display_name.lower()))[:10] proposed_ids=favorite_ids|{x.id for x in top_stations} 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 proposed_ids),key=lambda x:x.display_name.lower()) return render_template("fuel_form.html", vehicles=vehicles, stations=stations, favorite_stations=favorites, top_stations=top_stations, 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])