Files
fuel_track/app/main.py
T
Mateusz Gruszczyński 951b732db6 add projekt cpn support
2026-07-13 14:47:07 +02:00

413 lines
26 KiB
Python

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
from .vat import effective_vat_rate, global_vat_override, save_global_vat_override
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:
vat_rate = effective_vat_rate(settings.vat_rate, e.fueled_at)
c = calculate_costs(e.gross, 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(vat_rate) / 100)
if rule:
net = payable_price / (1 + float(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(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, "vat_rate": float(vat_rate)}
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, vat_override=global_vat_override())
@main.route("/vehicles", methods=["GET", "POST"])
@login_required
def vehicles():
if request.method == "POST":
v = Vehicle(name=request.form["name"], make=request.form["make"], model=request.form["model"], registration=request.form["registration"].upper(), fuel_type=request.form["fuel_type"], owner_id=current_user.id, has_fuel_card="has_fuel_card" in request.form, card_provider=request.form.get("card_provider"), current_odometer=int(request.form.get("current_odometer") or 0))
db.session.add(v); db.session.commit(); response = ok_response("Dodano pojazd", vehicle_id=v.id); return response or redirect(url_for("main.vehicles"))
company_id=current_user.company_id
users_q=User.query.filter_by(active=True)
cards_q=FuelCard.query.filter_by(active=True)
if current_user.role=='boss' and company_id:
users_q=users_q.filter_by(company_id=company_id);cards_q=cards_q.filter_by(company_id=company_id)
return render_template("vehicles.html", vehicles=accessible_vehicles(), fuel_cards=cards_q.order_by(FuelCard.name).all(), assignable_users=users_q.order_by(User.name).all())
@main.route("/vehicles/<int:vehicle_id>/share", methods=["POST"])
@login_required
def share_vehicle(vehicle_id):
vehicle = Vehicle.query.get_or_404(vehicle_id)
if current_user.role != "admin" and vehicle.owner_id != current_user.id:
response = error_response("Tylko właściciel lub admin może udostępniać auto", 403); return response or redirect(url_for("main.vehicles"))
user = User.query.filter_by(email=request.form["email"].strip().lower()).first()
if not user:
response = error_response("Nie znaleziono użytkownika", 404); return response or redirect(url_for("main.vehicles"))
if user not in vehicle.drivers: vehicle.drivers.append(user); db.session.commit()
response = ok_response("Udostępniono pojazd"); return response or redirect(url_for("main.vehicles"))
@main.post("/vehicles/<int:vehicle_id>/unshare/<int:user_id>")
@login_required
def unshare_vehicle(vehicle_id, user_id):
vehicle = Vehicle.query.get_or_404(vehicle_id)
if current_user.role != "admin" and vehicle.owner_id != current_user.id:
return error_response("Tylko właściciel lub admin może odebrać dostęp", 403)
user = User.query.get_or_404(user_id)
if user in vehicle.drivers:
vehicle.drivers.remove(user); db.session.commit()
response = ok_response("Odebrano dostęp do pojazdu")
return response or redirect(url_for("main.vehicles"))
@main.post("/vehicles/<int:vehicle_id>/card-policy")
@login_required
def update_card_policy(vehicle_id):
vehicle = Vehicle.query.get_or_404(vehicle_id)
if current_user.role != "admin" and vehicle.owner_id != current_user.id:
return error_response("Tylko właściciel lub admin może zmienić zasady rozliczeń", 403)
policy = vehicle.fuel_card_policy or FuelCardPolicy(vehicle=vehicle)
policy.use_orlen_last_price = "use_orlen_last_price" in request.form
policy.discount_percent = Decimal(request.form.get("discount_percent") or 0)
policy.surcharge_per_liter = Decimal(request.form.get("surcharge_per_liter") or 0)
policy.description = request.form.get("description", "").strip() or None
db.session.add(policy); db.session.commit()
response = ok_response("Zapisano zasady rozliczeń karty")
return response or redirect(url_for("main.vehicles"))
@main.route("/fuel", methods=["GET", "POST"])
@login_required
def fuel():
vehicles = accessible_vehicles()
if request.method == "POST":
vehicle = next((v for v in vehicles if v.id == int(request.form["vehicle_id"])), None)
if not vehicle:
response = error_response("Brak dostępu do pojazdu", 403); return response or redirect(url_for("main.fuel"))
odometer = int(request.form["odometer"])
if odometer < vehicle.current_odometer:
response = error_response("Licznik nie może być niższy od poprzedniego"); return response or redirect(url_for("main.fuel"))
fueled_at = datetime.fromisoformat(request.form["fueled_at"]); wholesale = None; source = None
station_company_id = request.form.get("station_company_id", type=int)
station_company = db.session.get(FuelStationCompany, station_company_id) if station_company_id else None
price_region = station_company.selected_region if station_company and station_company.selected_region else CompanySettings.query.first().region
try:
p = fetch_orlen_price(request.form["fuel_type"], fueled_at.date(), price_region); wholesale=p["price_per_liter"]; source=p["source"]
except Exception: pass
station_label = station_company.company_name if station_company else request.form.get("station")
e = FuelEntry(vehicle_id=vehicle.id, user_id=current_user.id, fueled_at=fueled_at, liters=Decimal(request.form["liters"]), price_per_liter=Decimal(request.form["price_per_liter"]), fuel_type=request.form["fuel_type"], odometer=odometer, station=station_label, station_company_id=station_company.id if station_company else None, invoice_number=request.form.get("invoice_number"), used_fuel_card="used_fuel_card" in request.form, wholesale_price=wholesale, wholesale_source=source)
vehicle.current_odometer = odometer; db.session.add(e); db.session.commit(); socketio.emit("fuel_added", {"vehicle": vehicle.name, "gross": round(e.gross,2)}); response = ok_response("Zapisano tankowanie", redirect=url_for("main.dashboard")); return response or redirect(url_for("main.dashboard"))
settings=current_user.company or CompanySettings.query.first()
allowed=list(settings.allowed_stations)
base_query=FuelStationCompany.query.filter_by(active=True)
stations=(base_query.filter(FuelStationCompany.id.in_([x.id for x in allowed])) if allowed else base_query).order_by(FuelStationCompany.station_count.desc(), FuelStationCompany.brand_name.asc()).all()
favorites=[x for x in current_user.favorite_stations if x in stations]
favorite_scope="user"
if not favorites and settings.favorite_stations:
favorites=[x for x in settings.favorite_stations if x in stations];favorite_scope="company"
if not favorites: favorites=sorted(stations,key=lambda x:(-x.station_count,x.company_name.lower()))[:10];favorite_scope="default"
favorites=sorted(favorites,key=lambda x:(-x.station_count,x.display_name.lower()))
favorite_ids={x.id for x in favorites}
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)]
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) * (1 + float(effective_vat_rate(settings.vat_rate, r.effective_date)) / 100 if include_vat else 1), 4)} for r in fuel_rows if r.region == region]
else:
chart[fuel] = [{"date": r.effective_date.isoformat(), "value": round(float(r.price_per_liter) * (1 + float(effective_vat_rate(settings.vat_rate, r.effective_date)) / 100 if include_vat else 1), 4)} for r in fuel_rows]
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,
vat_override=global_vat_override(),
)
@main.post("/admin/global-vat")
@login_required
@roles("admin")
def update_global_vat():
try:
save_global_vat_override(request.form)
db.session.commit()
response = ok_response("Zapisano globalne ustawienia VAT")
return response or redirect(url_for("main.admin_companies"))
except ValueError as exc:
db.session.rollback()
response = error_response(str(exc))
return response or redirect(url_for("main.admin_companies"))
@main.get("/admin/application")
@login_required
@roles("admin")
def admin_application():
return render_template("admin_application.html")
@main.get("/admin/users")
@login_required
@roles("boss", "admin")
def admin_users():
page=request.args.get("page",1,type=int);q=request.args.get("q","").strip();users_q=User.query
if current_user.role=="boss": users_q=users_q.filter_by(company_id=current_user.company_id)
if q: users_q=users_q.filter(db.or_(User.name.ilike(f"%{q}%"),User.email.ilike(f"%{q}%")))
users_page=users_q.order_by(User.name).paginate(page=page,per_page=25,error_out=False)
companies=CompanySettings.query.order_by(CompanySettings.name).all() if current_user.role=="admin" else [current_user.company]
cards=FuelCard.query.order_by(FuelCard.name).all()
if current_user.role=="boss": cards=cards.filter_by(company_id=current_user.company_id).all()
return render_template("admin_users.html",users_page=users_page,q=q,companies=[c for c in companies if c],fuel_cards=cards)
@main.post("/admin/users/<int:user_id>")
@login_required
@roles("admin")
def edit_user(user_id):
user = User.query.get_or_404(user_id)
try:
user.name = request.form["name"].strip(); user.email = request.form["email"].strip().lower(); user.role = request.form["role"]
user.active = "active" in request.form
password = request.form.get("password", "")
if password:
if len(password) < 8: raise ValueError("Nowe hasło musi mieć co najmniej 8 znaków.")
user.set_password(password)
db.session.commit(); response = ok_response(f"Zaktualizowano użytkownika {user.email}"); return response or redirect(url_for("main.admin"))
except (ValueError, IntegrityError) as exc:
db.session.rollback(); message = str(exc.orig) if isinstance(exc, IntegrityError) else str(exc); response = error_response(message); return response or redirect(url_for("main.admin", _anchor=f"user-{user_id}"))
@main.route("/stations")
@login_required
@roles("boss", "admin")
def stations():
q = request.args.get("q", "").strip()
page = request.args.get("page", 1, type=int)
sort = request.args.get("sort", "company_name")
direction = request.args.get("direction", "asc")
query = FuelStationCompany.query.filter_by(active=True)
if q:
terms = [term for term in q.split() if term]
for term in terms:
pattern = f"%{term}%"
query = query.filter(db.or_(FuelStationCompany.brand_name.ilike(pattern), FuelStationCompany.company_name.ilike(pattern), FuelStationCompany.nip.ilike(pattern), FuelStationCompany.regon.ilike(pattern), FuelStationCompany.station_names.ilike(pattern)))
columns = {"company_name": FuelStationCompany.brand_name, "station_count": FuelStationCompany.station_count, "nip": FuelStationCompany.nip, "regon": FuelStationCompany.regon, "selected_region": FuelStationCompany.selected_region}
column = columns.get(sort, FuelStationCompany.company_name)
query = query.order_by(column.desc() if direction == "desc" else column.asc(), FuelStationCompany.brand_name.asc())
station_page = query.paginate(page=page, per_page=30, error_out=False)
companies_q = CompanySettings.query.filter_by(active=True).order_by(CompanySettings.name.asc())
if current_user.role == "boss":
companies_q = companies_q.filter_by(id=current_user.company_id)
companies = companies_q.all()
requested_company_id = request.args.get("company_id", type=int)
if current_user.role == "boss":
company = current_user.company
else:
company = db.session.get(CompanySettings, requested_company_id) if requested_company_id else None
if company not in companies:
company = companies[0] if companies else None
if company is None:
abort(404, description="Brak aktywnej firmy do konfiguracji")
cards_q=FuelCard.query.filter_by(active=True, company_id=company.id)
cards=cards_q.order_by(FuelCard.name).all()
rules={(r.fuel_card_id,r.station_company_id):r for r in FuelCardStationRule.query.filter(FuelCardStationRule.fuel_card_id.in_([c.id for c in cards] or [-1])).all()}
return render_template("stations.html", station_page=station_page, q=q, regions=POLISH_REGIONS, sort=sort, direction=direction, allowed_ids={x.id for x in company.allowed_stations}, favorite_ids={x.id for x in company.favorite_stations}, company=company, companies=companies, fuel_cards=cards, card_rules=rules)
@main.post("/stations/sync")
@login_required
@roles("boss", "admin")
def stations_sync():
try:
companies = aggregate_ure_companies(fetch_ure_stations())
added, updated = sync_station_catalog(companies)
db.session.commit()
response = ok_response(f"Katalog URE: dodano {added}, zaktualizowano {updated}.")
return response or redirect(url_for("main.stations"))
except Exception as exc:
db.session.rollback(); response = error_response(f"Nie udało się pobrać danych URE: {exc}", 502); return response or redirect(url_for("main.stations"))
@main.post("/stations/<int:station_id>")
@login_required
@roles("boss", "admin")
def update_station(station_id):
station = FuelStationCompany.query.get_or_404(station_id)
region = request.form.get("selected_region", "").strip().lower()
if region and region not in station.region_list:
return error_response("Wybrane województwo nie występuje w danych tej firmy.")
station.selected_region = region
station.active = "active" in request.form
db.session.commit(); response = ok_response(f"Zapisano ustawienia firmy {station.company_name}"); return response or redirect(url_for("main.stations"))
@main.get("/api/stations")
@login_required
def station_suggestions():
q = request.args.get("q", "").strip()
query = FuelStationCompany.query.filter_by(active=True)
if q:
pattern = f"%{q}%"
query = query.filter(db.or_(FuelStationCompany.brand_name.ilike(pattern), FuelStationCompany.company_name.ilike(pattern), FuelStationCompany.station_names.ilike(pattern), FuelStationCompany.nip.ilike(pattern)))
rows = query.order_by(FuelStationCompany.company_name).limit(30).all()
return jsonify([{"id": x.id, "name": x.company_name, "brands": x.station_name_list[:5], "region": x.selected_region, "last_price": x.use_orlen_last_price} for x in rows])