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 @@
- + + +Detal brutto, ostateczna kwota do zapłaty brutto oraz różnica. Rabaty i dopłaty użytkownika są uwzględniane.
| Okres | Pozycje | Faktura* | Koszt po VAT |
|---|---|---|---|
| {{key}} | {{row.count}} | {{'%.2f'|format(row.gross)}} zł | {{'%.2f'|format(row.payable)}} zł |
| Brak danych | |||
| Data | Auto | Stacja | Litry | Detal brutto | Detal netto | Last Price netto | Oszczędność netto | Faktura brutto | Koszt 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. | |||||||||
| Data | Auto | Stacja | Litry | Detal brutto | Detal netto | Last Price netto | Oszczędność netto | Faktura brutto | Koszt 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. | |||||||||