From 659f04f05f6dc9ab3e40ffc7feca5a603e7cf6e3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Gruszczy=C5=84ski?= Date: Sun, 19 Jul 2026 19:00:04 +0200 Subject: [PATCH] zapamietanie akcji --- app/api/fuel_entries.py | 2 ++ app/database_migrations.py | 46 +++++++++++++++++++++++++++++++++++ app/main.py | 8 ++++-- app/models.py | 6 +++++ app/static/css/components.css | 8 +++++- app/static/css/layout.css | 8 +++--- app/templates/base.html | 4 ++- app/templates/dashboard.html | 2 +- app/vat.py | 45 ++++++++++++++++++++++++++++++++++ 9 files changed, 121 insertions(+), 8 deletions(-) diff --git a/app/api/fuel_entries.py b/app/api/fuel_entries.py index 0044bc1..ebd508a 100644 --- a/app/api/fuel_entries.py +++ b/app/api/fuel_entries.py @@ -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) diff --git a/app/database_migrations.py b/app/database_migrations.py index e1f32cd..33c4276 100644 --- a/app/database_migrations.py +++ b/app/database_migrations.py @@ -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: diff --git a/app/main.py b/app/main.py index f850af9..425543d 100644 --- a/app/main.py +++ b/app/main.py @@ -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) diff --git a/app/models.py b/app/models.py index 3b4f152..c4b2bf0 100644 --- a/app/models.py +++ b/app/models.py @@ -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") diff --git a/app/static/css/components.css b/app/static/css/components.css index 815902a..3ae1ea6 100644 --- a/app/static/css/components.css +++ b/app/static/css/components.css @@ -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} diff --git a/app/static/css/layout.css b/app/static/css/layout.css index 0b6b68f..435610d 100644 --- a/app/static/css/layout.css +++ b/app/static/css/layout.css @@ -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))}} diff --git a/app/templates/base.html b/app/templates/base.html index 698cd63..6a6f113 100644 --- a/app/templates/base.html +++ b/app/templates/base.html @@ -2,7 +2,9 @@ - + + + {% block title %}FuelTrack{% endblock %} diff --git a/app/templates/dashboard.html b/app/templates/dashboard.html index 60ae445..cf27b14 100644 --- a/app/templates/dashboard.html +++ b/app/templates/dashboard.html @@ -10,5 +10,5 @@
Wykres brutto: 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.

Kwoty brutto według pojazdu

Detal brutto, ostateczna kwota do zapłaty brutto oraz różnica. Rabaty i dopłaty użytkownika są uwzględniane.

Brak danych dla wybranego miesiąca.

Okresy faktur

{% if split_enabled %}Podział według ustawień firmy{% else %}Podział dzienny wyłączony{% endif %}{% for key,row in invoices.items() %}{% else %}{% endfor %}
OkresPozycjeFaktura*Koszt po VAT
{{key}}{{row.count}}{{'%.2f'|format(row.gross)}} zł{{'%.2f'|format(row.payable)}} zł
Brak danych
* Kwota brutto: cena rozliczeniowa netto + VAT właściwy dla firmy.
-

Tankowania

{% for e in entries %}{% set s=settlements[e.id] %}{% else %}{% endfor %}
DataAutoStacjaLitryDetal bruttoDetal nettoLast Price nettoOszczędność nettoFaktura bruttoKoszt po VAT
{{e.fueled_at.strftime('%d.%m.%Y')}}{{e.vehicle.name}}{{e.station or '—'}}{{e.liters}}{{'%.4f'|format(s.retail_price)}} zł/l
{{'%.2f'|format(s.retail_gross)}} zł
{{'%.4f'|format(s.retail_net_price)}} zł/l
{{'%.2f'|format(s.retail_net_total)}} zł
{% if s.uses_last_price %}Last Price
{{'%.4f'|format(s.settlement_net)}} zł/l{% else %}{{'%.4f'|format(s.settlement_net)}} zł/l{% endif %}
{{'%.2f'|format(s.settlement_net_total)}} zł
{% if s.uses_last_price %}{{'%.2f'|format(s.saving_net)}} zł
{{'%.4f'|format(s.saving_net_per_liter)}} zł/l{% else %}—{% endif %}
{{'%.4f'|format(s.invoice_price)}} zł/l
{{'%.2f'|format(s.invoice_gross)}} zł
VAT {{'%.2f'|format(s.vat_rate)}}%
{{'%.4f'|format(s.effective_price)}} zł/l
{{'%.2f'|format(s.effective_cost)}} zł
odliczenie VAT {{'%.0f'|format(s.vat_deduction_percent)}}%
Brak tankowań w tym miesiącu.
+

Tankowania

{% for e in entries %}{% set s=settlements[e.id] %}{% else %}{% endfor %}
DataAutoStacjaLitryDetal bruttoDetal nettoLast Price nettoOszczędność nettoFaktura bruttoKoszt po VAT
{{e.fueled_at.strftime('%d.%m.%Y')}}{% if e.vat_action_applied %}
{{e.vat_action_name or 'Akcja rządowa'}}{% endif %}
{{e.vehicle.name}}{{e.station or '—'}}{{e.liters}}{{'%.4f'|format(s.retail_price)}} zł/l
{{'%.2f'|format(s.retail_gross)}} zł
{{'%.4f'|format(s.retail_net_price)}} zł/l
{{'%.2f'|format(s.retail_net_total)}} zł
{% if s.uses_last_price %}Last Price
{{'%.4f'|format(s.settlement_net)}} zł/l{% else %}{{'%.4f'|format(s.settlement_net)}} zł/l{% endif %}
{{'%.2f'|format(s.settlement_net_total)}} zł
{% if s.uses_last_price %}{{'%.2f'|format(s.saving_net)}} zł
{{'%.4f'|format(s.saving_net_per_liter)}} zł/l{% else %}—{% endif %}
{{'%.4f'|format(s.invoice_price)}} zł/l
{{'%.2f'|format(s.invoice_gross)}} zł
VAT {{'%.2f'|format(s.vat_rate)}}%
{{'%.4f'|format(s.effective_price)}} zł/l
{{'%.2f'|format(s.effective_cost)}} zł
odliczenie VAT {{'%.0f'|format(s.vat_deduction_percent)}}%
Brak tankowań w tym miesiącu.
{% endblock %}{% block scripts %}{% endblock %} diff --git a/app/vat.py b/app/vat.py index efbd5e8..eb6d7b4 100644 --- a/app/vat.py +++ b/app/vat.py @@ -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()