zapamietanie akcji

This commit is contained in:
Mateusz Gruszczyński
2026-07-19 19:00:04 +02:00
parent e26d9e4ff3
commit 659f04f05f
9 changed files with 121 additions and 8 deletions
+2
View File
@@ -15,6 +15,7 @@ from ..extensions import db
from ..models import AppSetting, CompanySettings, FuelCard, FuelCardPolicy, FuelCardStationRule, FuelEntry, FuelStationCompany, FuelStationPoint, OrlenPrice, User, Vehicle
from ..services import POLISH_REGIONS, aggregate_ure_companies, calculate_costs, fetch_orlen_price, fetch_orlen_range, fetch_ure_stations, invoice_period
from ..station_catalog import sync_station_catalog
from ..vat import freeze_vat_action
FUEL_TYPES = ("PB95", "PB98", "DIESEL", "LPG")
@@ -44,5 +45,6 @@ def create_fuel_entry():
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)
freeze_vat_action(e, settings.vat_rate)
vehicle.current_odometer=odo;db.session.add(e);db.session.commit();return response({'id':e.id,'redirect':url_for('main.dashboard')},'Zapisano tankowanie',201)
except Exception as exc:db.session.rollback();return fail(str(exc),400)
+46
View File
@@ -21,8 +21,54 @@ def add_station_access_mode() -> None:
))
def add_fuel_entry_vat_action_snapshot() -> None:
inspector = inspect(db.engine)
columns = {column["name"] for column in inspector.get_columns("fuel_entry")}
additions = {
"vat_action_frozen": "BOOLEAN NOT NULL DEFAULT 0",
"vat_action_applied": "BOOLEAN NOT NULL DEFAULT 0",
"vat_action_name": "VARCHAR(160)",
"vat_action_rate": "NUMERIC(6, 3)",
"vat_action_start": "DATE",
"vat_action_end": "DATE",
}
for name, definition in additions.items():
if name not in columns:
db.session.execute(text(f"ALTER TABLE fuel_entry ADD COLUMN {name} {definition}"))
settings = dict(db.session.execute(text(
"SELECT key, value FROM app_setting WHERE key IN "
"('global_vat_override_enabled','global_vat_override_name','global_vat_override_rate',"
"'global_vat_override_start','global_vat_override_end')"
)).all())
if settings.get("global_vat_override_enabled") == "1" and settings.get("global_vat_override_rate"):
conditions = ["vat_action_applied = 0"]
params = {
"name": settings.get("global_vat_override_name") or "Projekt rządowy",
"rate": settings["global_vat_override_rate"],
"start": settings.get("global_vat_override_start") or None,
"end": settings.get("global_vat_override_end") or None,
}
if params["start"]:
conditions.append("DATE(fueled_at) >= DATE(:start)")
if params["end"]:
conditions.append("DATE(fueled_at) <= DATE(:end)")
db.session.execute(text(f"""
UPDATE fuel_entry
SET vat_action_frozen = 1,
vat_action_applied = 1,
vat_action_name = :name,
vat_action_rate = :rate,
vat_action_start = :start,
vat_action_end = :end
WHERE {' AND '.join(conditions)}
"""), params)
db.session.execute(text("UPDATE fuel_entry SET vat_action_frozen = 1 WHERE vat_action_frozen = 0"))
MIGRATIONS: tuple[Migration, ...] = (
Migration("20260713_company_station_access_mode", add_station_access_mode),
Migration("20260719_fuel_entry_vat_action_snapshot", add_fuel_entry_vat_action_snapshot),
)
def ensure_migration_table() -> None:
+6 -2
View File
@@ -11,7 +11,7 @@ from .models import User, Vehicle, FuelEntry, CompanySettings, AppSetting, Orlen
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
from .vat import entry_vat_rate, freeze_vat_action, global_vat_override, save_global_vat_override
from .domain.costs import calculate_net_price_costs, calculate_net_price_comparison
main = Blueprint("main", __name__)
@@ -70,6 +70,7 @@ def parse_fuel_entry_form(entry):
fuel_type = (request.form.get("fuel_type") or "").upper()
if fuel_type not in FUEL_TYPES:
raise ValueError("Nieprawidłowy rodzaj paliwa")
previous_fueled_at = entry.fueled_at
fueled_at = datetime.fromisoformat(request.form.get("fueled_at", ""))
liters = Decimal(request.form.get("liters", ""))
price = Decimal(request.form.get("price_per_liter", ""))
@@ -99,6 +100,8 @@ def parse_fuel_entry_form(entry):
wholesale_price = (request.form.get("wholesale_price") or "").strip()
entry.wholesale_price = Decimal(wholesale_price) if wholesale_price else None
entry.wholesale_source = (request.form.get("wholesale_source") or "").strip() or None
settings = vehicle.company or (vehicle.owner.company if vehicle.owner else None) or CompanySettings.query.first()
freeze_vat_action(entry, settings.vat_rate, previous_fueled_at)
return vehicle
@@ -110,7 +113,7 @@ def calculate_entry_settlement(entry, fallback_settings=None):
separately and never counted as a Last Price saving.
"""
entry_settings = entry.vehicle.company or (entry.vehicle.owner.company if entry.vehicle and entry.vehicle.owner else None) or fallback_settings or CompanySettings.query.first()
vat_rate = effective_vat_rate(entry_settings.vat_rate, entry.fueled_at)
vat_rate = entry_vat_rate(entry, entry_settings.vat_rate)
vat_deduction_percent = entry_settings.vat_deduction_percent
retail_price = Decimal(entry.price_per_liter)
rule = FuelCardStationRule.query.filter_by(
@@ -318,6 +321,7 @@ def fuel():
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)
freeze_vat_action(e, settings.vat_rate)
vehicle.current_odometer = odometer; db.session.add(e); db.session.commit(); response = ok_response("Zapisano tankowanie", redirect=url_for("main.dashboard")); return response or redirect(url_for("main.dashboard"))
allowed=list(settings.allowed_stations)
company_favorites=list(settings.favorite_stations)
+6
View File
@@ -171,6 +171,12 @@ class FuelEntry(db.Model):
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))
vat_action_frozen = db.Column(db.Boolean, default=False, nullable=False)
vat_action_applied = db.Column(db.Boolean, default=False, nullable=False)
vat_action_name = db.Column(db.String(160))
vat_action_rate = db.Column(db.Numeric(6, 3))
vat_action_start = db.Column(db.Date)
vat_action_end = db.Column(db.Date)
created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False)
vehicle = db.relationship("Vehicle", back_populates="fuel_entries")
user = db.relationship("User")
+7 -1
View File
@@ -16,6 +16,12 @@
[data-bs-theme="dark"] .table thead th{color:#adb5bd}
[data-bs-theme="dark"] .text-body-secondary,[data-bs-theme="dark"] .text-muted{color:#aeb4bb!important}
[data-bs-theme="dark"] .bg-body-tertiary{background-color:#181b1f!important}
[data-bs-theme="dark"] .dropdown-menu{background:#202327;border-color:#3a3f45;box-shadow:0 .75rem 2rem rgba(0,0,0,.35)}
[data-bs-theme="dark"] .dropdown-menu{background:#202327;border-color:#3a3f45;box-shadow:0 .4rem 1.1rem rgba(0,0,0,.22)}
[data-bs-theme="dark"] .dropdown-item{color:#e9ecef}
[data-bs-theme="dark"] .dropdown-item:hover,[data-bs-theme="dark"] .dropdown-item:focus{background:#2b3035;color:#fff}
.vat-action-row>td{background-color:rgba(var(--bs-warning-rgb),.075)}
.vat-action-row:hover>td{background-color:rgba(var(--bs-warning-rgb),.13)!important}
.vat-action-badge{background:rgba(var(--bs-warning-rgb),.2);color:var(--bs-body-color);border:1px solid rgba(var(--bs-warning-rgb),.45);margin-top:.25rem}
[data-bs-theme="dark"] .vat-action-row>td{background-color:rgba(var(--bs-warning-rgb),.09)}
[data-bs-theme="dark"] .vat-action-row:hover>td{background-color:rgba(var(--bs-warning-rgb),.15)!important}
+5 -3
View File
@@ -1,5 +1,5 @@
:root{--app-radius:1rem;--app-shadow:0 .4rem 1.5rem rgba(15,23,42,.07);--app-shadow-hover:0 .75rem 2rem rgba(15,23,42,.11)}
html,body{min-height:100%}body{min-height:100vh;display:flex;flex-direction:column;background:var(--bs-tertiary-bg)}
html,body{min-height:100%}body{min-height:100vh;min-height:100dvh;display:flex;flex-direction:column;background:var(--bs-tertiary-bg);padding-left:env(safe-area-inset-left);padding-right:env(safe-area-inset-right)}
.app-navbar{background:color-mix(in srgb,var(--bs-body-bg) 92%,transparent);border-bottom:1px solid var(--bs-border-color-translucent);backdrop-filter:blur(14px);box-shadow:0 .15rem .75rem rgba(15,23,42,.04)}
.navbar-brand{font-weight:800;letter-spacing:-.04em;white-space:nowrap}.app-brand,.auth-brand{font-size:1.4rem;line-height:1;text-decoration:none}.brand-fuel{color:var(--bs-emphasis-color)}.brand-track{color:var(--bs-primary)}
.app-brand::after,.auth-brand::after{content:"";display:inline-block;width:.38rem;height:.38rem;margin-left:.18rem;border-radius:50%;background:var(--bs-warning);box-shadow:0 0 0 .2rem color-mix(in srgb,var(--bs-warning) 18%,transparent);vertical-align:.15em}
@@ -17,11 +17,13 @@ html,body{min-height:100%}body{min-height:100vh;display:flex;flex-direction:colu
.app-brand{font-family:inherit;font-size:1.4rem!important;font-weight:800!important;line-height:1!important;letter-spacing:-.04em!important}
.app-navbar .nav-link{font-size:1rem;line-height:1.5}
[data-bs-theme="dark"]{--app-shadow:0 .45rem 1.6rem rgba(0,0,0,.28);--app-shadow-hover:0 .8rem 2.2rem rgba(0,0,0,.38)}
[data-bs-theme="dark"]{--app-shadow:0 .25rem .9rem rgba(0,0,0,.16);--app-shadow-hover:0 .45rem 1.25rem rgba(0,0,0,.22)}
[data-bs-theme="dark"] body{background:#17191c;color:#f1f3f5}
[data-bs-theme="dark"] .app-navbar{background:rgba(24,26,29,.94);border-bottom-color:#34383d;box-shadow:0 .2rem 1rem rgba(0,0,0,.32)}
[data-bs-theme="dark"] .app-navbar{background:rgba(24,26,29,.94);border-bottom-color:#34383d;box-shadow:0 .1rem .45rem rgba(0,0,0,.18)}
[data-bs-theme="dark"] .app-footer{background:#1b1d20;border-top-color:#34383d}
[data-bs-theme="dark"] .brand-fuel{color:#f8f9fa}
[data-bs-theme="dark"] .auth-page{background:radial-gradient(circle at 20% 10%,rgba(55,139,230,.16),transparent 36%),radial-gradient(circle at 85% 85%,rgba(255,193,7,.08),transparent 32%),#141619}
@media(max-width:991.98px){.app-main{padding-top:1.35rem}.app-navbar .navbar-collapse{border-top:1px solid var(--bs-border-color-translucent);margin-top:.75rem}.app-navbar .nav-link{padding:.7rem .8rem}.page-header{align-items:stretch;flex-direction:column}}
@media(max-width:575.98px){.app-main{padding-left:1rem;padding-right:1rem}.page-header h1{font-size:1.65rem}.auth-main{padding-top:1.25rem;padding-bottom:1.25rem}}
@supports(padding:max(0px)){.app-navbar{padding-top:max(.5rem,env(safe-area-inset-top))}.app-footer{padding-bottom:max(1.25rem,env(safe-area-inset-bottom))}}
+3 -1
View File
@@ -2,7 +2,9 @@
<html lang="pl" data-bs-theme="{{ app_theme.mode }}">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<meta name="theme-color" content="#0d6efd">
<title>{% block title %}FuelTrack{% endblock %}</title>
<link id="bootstrap-theme" rel="stylesheet" href="{{ app_theme.url }}">
+1 -1
View File
@@ -10,5 +10,5 @@
</div>
<div class="alert alert-info small"><strong>Wykres brutto:</strong> porównuje wartość po cenie detalicznej z ostateczną kwotą faktury brutto. Cena rozliczeniowa uwzględnia Last Price albo cenę detaliczną netto, następnie rabat procentowy i dopłatę przypisaną do karty/stacji. Oszczędność handlowa Last Price nadal jest liczona netto.</div>
<div class="row g-4"><div class="col-lg-7"><div class="card"><div class="card-body"><h2 class="h5">Kwoty brutto według pojazdu</h2><p class="small text-body-secondary">Detal brutto, ostateczna kwota do zapłaty brutto oraz różnica. Rabaty i dopłaty użytkownika są uwzględniane.</p><div class="chart-box"><canvas id="cost-chart"></canvas><div id="cost-chart-empty" class="chart-empty d-none">Brak danych dla wybranego miesiąca.</div></div></div></div></div><div class="col-lg-5"><div class="card h-100"><div class="card-body"><h2 class="h5">Okresy faktur</h2><small class="text-body-secondary">{% if split_enabled %}Podział według ustawień firmy{% else %}Podział dzienny wyłączony{% endif %}</small><table class="table mt-2"><thead><tr><th>Okres</th><th>Pozycje</th><th>Faktura*</th><th>Koszt po VAT</th></tr></thead><tbody>{% for key,row in invoices.items() %}<tr><td>{{key}}</td><td>{{row.count}}</td><td>{{'%.2f'|format(row.gross)}} zł</td><td>{{'%.2f'|format(row.payable)}} zł</td></tr>{% else %}<tr><td colspan="4">Brak danych</td></tr>{% endfor %}</tbody></table><div class="small text-body-secondary">* Kwota brutto: cena rozliczeniowa netto + VAT właściwy dla firmy.</div></div></div></div>
<div class="card mt-4"><div class="card-body"><h2 class="h5">Tankowania</h2><div class="table-responsive"><table class="table table-hover align-middle"><thead><tr><th>Data</th><th>Auto</th><th>Stacja</th><th>Litry</th><th>Detal brutto</th><th>Detal netto</th><th>Last Price netto</th><th>Oszczędność netto</th><th>Faktura brutto</th><th>Koszt po VAT</th></tr></thead><tbody>{% for e in entries %}{% set s=settlements[e.id] %}<tr><td>{{e.fueled_at.strftime('%d.%m.%Y')}}</td><td>{{e.vehicle.name}}</td><td>{{e.station or '—'}}</td><td>{{e.liters}}</td><td>{{'%.4f'|format(s.retail_price)}} zł/l<br><small>{{'%.2f'|format(s.retail_gross)}} zł</small></td><td>{{'%.4f'|format(s.retail_net_price)}} zł/l<br><small>{{'%.2f'|format(s.retail_net_total)}} zł</small></td><td>{% if s.uses_last_price %}<span class="badge text-bg-info">Last Price</span><br>{{'%.4f'|format(s.settlement_net)}} zł/l{% else %}{{'%.4f'|format(s.settlement_net)}} zł/l{% endif %}<br><small>{{'%.2f'|format(s.settlement_net_total)}} zł</small></td><td>{% if s.uses_last_price %}<strong>{{'%.2f'|format(s.saving_net)}} zł</strong><br><small>{{'%.4f'|format(s.saving_net_per_liter)}} zł/l</small>{% else %}—{% endif %}</td><td>{{'%.4f'|format(s.invoice_price)}} zł/l<br><strong>{{'%.2f'|format(s.invoice_gross)}} zł</strong><br><small>VAT {{'%.2f'|format(s.vat_rate)}}%</small></td><td>{{'%.4f'|format(s.effective_price)}} zł/l<br><strong>{{'%.2f'|format(s.effective_cost)}} zł</strong><br><small>odliczenie VAT {{'%.0f'|format(s.vat_deduction_percent)}}%</small></td></tr>{% else %}<tr><td colspan="10">Brak tankowań w tym miesiącu.</td></tr>{% endfor %}</tbody></table></div></div></div>
<div class="card mt-4"><div class="card-body"><h2 class="h5">Tankowania</h2><div class="table-responsive"><table class="table table-hover align-middle"><thead><tr><th>Data</th><th>Auto</th><th>Stacja</th><th>Litry</th><th>Detal brutto</th><th>Detal netto</th><th>Last Price netto</th><th>Oszczędność netto</th><th>Faktura brutto</th><th>Koszt po VAT</th></tr></thead><tbody>{% for e in entries %}{% set s=settlements[e.id] %}<tr class="{% if e.vat_action_applied %}vat-action-row{% endif %}"{% if e.vat_action_applied %} title="Akcja: {{ e.vat_action_name }} · VAT {{ '%.2f'|format(e.vat_action_rate|float) }}%{% if e.vat_action_start or e.vat_action_end %} · {{ e.vat_action_start or 'bez daty początkowej' }}{{ e.vat_action_end or 'bez daty końcowej' }}{% endif %}"{% endif %}><td>{{e.fueled_at.strftime('%d.%m.%Y')}}{% if e.vat_action_applied %}<br><span class="badge vat-action-badge">{{e.vat_action_name or 'Akcja rządowa'}}</span>{% endif %}</td><td>{{e.vehicle.name}}</td><td>{{e.station or '—'}}</td><td>{{e.liters}}</td><td>{{'%.4f'|format(s.retail_price)}} zł/l<br><small>{{'%.2f'|format(s.retail_gross)}} zł</small></td><td>{{'%.4f'|format(s.retail_net_price)}} zł/l<br><small>{{'%.2f'|format(s.retail_net_total)}} zł</small></td><td>{% if s.uses_last_price %}<span class="badge text-bg-info">Last Price</span><br>{{'%.4f'|format(s.settlement_net)}} zł/l{% else %}{{'%.4f'|format(s.settlement_net)}} zł/l{% endif %}<br><small>{{'%.2f'|format(s.settlement_net_total)}} zł</small></td><td>{% if s.uses_last_price %}<strong>{{'%.2f'|format(s.saving_net)}} zł</strong><br><small>{{'%.4f'|format(s.saving_net_per_liter)}} zł/l</small>{% else %}—{% endif %}</td><td>{{'%.4f'|format(s.invoice_price)}} zł/l<br><strong>{{'%.2f'|format(s.invoice_gross)}} zł</strong><br><small>VAT {{'%.2f'|format(s.vat_rate)}}%</small></td><td>{{'%.4f'|format(s.effective_price)}} zł/l<br><strong>{{'%.2f'|format(s.effective_cost)}} zł</strong><br><small>odliczenie VAT {{'%.0f'|format(s.vat_deduction_percent)}}%</small></td></tr>{% else %}<tr><td colspan="10">Brak tankowań w tym miesiącu.</td></tr>{% endfor %}</tbody></table></div></div></div>
{% endblock %}{% block scripts %}<script>FuelTrack.renderBarChart('cost-chart', {{by_vehicle|tojson}});</script>{% endblock %}
+45
View File
@@ -55,6 +55,51 @@ def effective_vat_rate(nominal_rate, on_date=None):
return override["rate"]
def vat_override_for_date(nominal_rate, on_date=None):
"""Return a complete, immutable VAT-action snapshot for a date."""
override = global_vat_override()
if isinstance(on_date, datetime):
on_date = on_date.date()
on_date = on_date or date.today()
applies = bool(
override["enabled"]
and override["rate"] is not None
and (not override["start"] or on_date >= override["start"])
and (not override["end"] or on_date <= override["end"])
)
return {
"applied": applies,
"name": override["name"] if applies else None,
"rate": override["rate"] if applies else Decimal(str(nominal_rate)),
"start": override["start"] if applies else None,
"end": override["end"] if applies else None,
}
def freeze_vat_action(entry, nominal_rate, previous_fueled_at=None):
"""Attach VAT action conditions to an entry without rewriting valid history."""
fueled_date = entry.fueled_at.date() if isinstance(entry.fueled_at, datetime) else entry.fueled_at
previous_date = previous_fueled_at.date() if isinstance(previous_fueled_at, datetime) else previous_fueled_at
if entry.vat_action_frozen and (previous_date is None or previous_date == fueled_date):
return
snapshot = vat_override_for_date(nominal_rate, fueled_date)
entry.vat_action_frozen = True
entry.vat_action_applied = snapshot["applied"]
entry.vat_action_name = snapshot["name"]
entry.vat_action_rate = snapshot["rate"] if snapshot["applied"] else None
entry.vat_action_start = snapshot["start"]
entry.vat_action_end = snapshot["end"]
def entry_vat_rate(entry, nominal_rate):
if entry.vat_action_frozen:
if entry.vat_action_applied and entry.vat_action_rate is not None:
return Decimal(str(entry.vat_action_rate))
return Decimal(str(nominal_rate))
return effective_vat_rate(nominal_rate, entry.fueled_at)
def save_global_vat_override(form):
enabled = "enabled" in form
name = (form.get("name") or "Projekt rządowy").strip()