add projekt cpn support
This commit is contained in:
+26
-9
@@ -11,6 +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
|
||||
|
||||
main = Blueprint("main", __name__)
|
||||
FUEL_TYPES = ("PB95", "PB98", "DIESEL", "LPG")
|
||||
@@ -63,7 +64,8 @@ def dashboard():
|
||||
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)
|
||||
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)
|
||||
@@ -71,21 +73,21 @@ def dashboard():
|
||||
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)
|
||||
payable_price = float(e.wholesale_price) * (1 + float(vat_rate) / 100)
|
||||
if rule:
|
||||
net = payable_price / (1 + float(settings.vat_rate) / 100)
|
||||
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(settings.vat_rate) / 100)
|
||||
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}
|
||||
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)
|
||||
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
|
||||
@@ -200,13 +202,12 @@ def orlen_data():
|
||||
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]
|
||||
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) * multiplier, 4)} for r in fuel_rows]
|
||||
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")
|
||||
@@ -284,8 +285,24 @@ def admin_companies():
|
||||
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")
|
||||
|
||||
@@ -7,6 +7,17 @@
|
||||
</div>
|
||||
{% include '_admin_nav.html' %}
|
||||
|
||||
{% if current_user.role == 'admin' %}
|
||||
<div class="card mb-4 border-warning"><div class="card-header"><strong>Globalny, czasowy VAT</strong></div><div class="card-body">
|
||||
<form action="{{ url_for('main.update_global_vat') }}" method="post" class="ajax-form row g-3">
|
||||
<div class="col-12"><div class="form-check form-switch"><input class="form-check-input" type="checkbox" name="enabled" id="global-vat-enabled" {% if vat_override.enabled %}checked{% endif %}><label class="form-check-label" for="global-vat-enabled">Nadpisz VAT we wszystkich firmach</label></div></div>
|
||||
<div class="col-md-4"><label class="form-label">Nazwa projektu</label><input class="form-control" name="name" value="{{ vat_override.name }}" placeholder="Np. CPN"></div>
|
||||
<div class="col-md-2"><label class="form-label">VAT (%)</label><input class="form-control" type="number" min="0" max="100" step="0.01" name="rate" value="{{ vat_override.rate if vat_override.rate is not none else '' }}" placeholder="8"></div>
|
||||
<div class="col-md-3"><label class="form-label">Obowiązuje od</label><input class="form-control" type="date" name="start" value="{{ vat_override.start.isoformat() if vat_override.start else '' }}"></div>
|
||||
<div class="col-md-3"><label class="form-label">Obowiązuje do</label><input class="form-control" type="date" name="end" value="{{ vat_override.end.isoformat() if vat_override.end else '' }}"></div>
|
||||
<div class="col-12 d-flex justify-content-between align-items-center"><small class="text-body-secondary">Stawka zastępuje VAT każdej firmy wyłącznie w podanym okresie. Odliczenie 50% przy VAT 8% oznacza odliczenie połowy podatku, czyli 4% podstawy netto.</small><button class="btn btn-warning">Zapisz globalny VAT</button></div>
|
||||
</form></div></div>
|
||||
{% endif %}
|
||||
<div class="row g-4">
|
||||
<div class="col-xl-4 col-xxl-3">
|
||||
<div class="card h-100">
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
{% extends 'base.html' %}{% block content %}
|
||||
<div class="d-flex justify-content-between align-items-center mb-3"><div><h1 class="h2 mb-0">Podsumowanie kosztów</h1><small class="text-body-secondary">{{ settings.name }} · VAT do odliczenia {{ settings.vat_deduction_percent }}%</small></div><form action="{{url_for('main.dashboard')}}" class="ajax-nav-form"><input class="form-control" type="month" name="month" value="{{month}}"></form></div>
|
||||
{% if vat_override.enabled %}<div class="alert alert-warning"><strong>{{ vat_override.name }}:</strong> globalny VAT {{ vat_override.rate }}%{% if vat_override.start %} od {{ vat_override.start.strftime('%d.%m.%Y') }}{% endif %}{% if vat_override.end %} do {{ vat_override.end.strftime('%d.%m.%Y') }}{% endif %}. Rozliczenia są liczone według daty tankowania.</div>{% endif %}
|
||||
<div class="row g-3 mb-4"><div class="col-6 col-lg-3"><div class="card metric"><div class="card-body"><small>Brutto</small><strong>{{ '%.2f'|format(totals.gross) }} zł</strong></div></div></div><div class="col-6 col-lg-3"><div class="card metric"><div class="card-body"><small>VAT odliczony</small><strong>{{ '%.2f'|format(totals.deductible_vat) }} zł</strong></div></div></div><div class="col-6 col-lg-3"><div class="card metric"><div class="card-body"><small>Koszt po VAT</small><strong>{{ '%.2f'|format(totals.final_cost) }} zł</strong></div></div></div><div class="col-6 col-lg-3"><div class="card metric"><div class="card-body"><small>Litry</small><strong>{{ '%.2f'|format(totals.liters) }} l</strong></div></div></div></div>
|
||||
<div class="row g-4"><div class="col-lg-7"><div class="card"><div class="card-body"><h2 class="h5">Koszt według pojazdu</h2><p class="small text-body-secondary">Cena na dystrybutorze a kwota do zapłaty według cennika karty.</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 dnia {{settings.invoice_split_day}}{% else %}Podział dzienny wyłączony{% endif %}</small><table class="table mt-2"><thead><tr><th>Okres</th><th>Pozycje</th><th>Detal</th><th>Do zapłaty</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></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>Paliwo</th><th>Litry</th><th>Cena detal.</th><th>Orlen last</th><th>Do zapłaty</th><th>Razem</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.fuel_type}}</td><td>{{e.liters}}</td><td>{{'%.4f'|format(s.normal_price)}} zł/l</td><td>{% if e.wholesale_price %}{{'%.4f'|format(e.wholesale_price|float)}} zł/l{% else %}—{% endif %}</td><td>{% if s.uses_last_price %}<span class="badge text-bg-info">last price</span> {% endif %}{{'%.4f'|format(s.payable_price)}} zł/l</td><td><span class="text-body-secondary text-decoration-line-through">{{'%.2f'|format(s.normal_gross)}} zł</span><br><strong>{{'%.2f'|format(s.payable_gross)}} zł</strong></td></tr>{% else %}<tr><td colspan="9">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>Paliwo</th><th>Litry</th><th>Cena detal.</th><th>Orlen last</th><th>Do zapłaty</th><th>VAT</th><th>Razem</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.fuel_type}}</td><td>{{e.liters}}</td><td>{{'%.4f'|format(s.normal_price)}} zł/l</td><td>{% if e.wholesale_price %}{{'%.4f'|format(e.wholesale_price|float)}} zł/l{% else %}—{% endif %}</td><td>{% if s.uses_last_price %}<span class="badge text-bg-info">last price</span> {% endif %}{{'%.4f'|format(s.payable_price)}} zł/l</td><td>{{'%.2f'|format(s.vat_rate)}}%</td><td><span class="text-body-secondary text-decoration-line-through">{{'%.2f'|format(s.normal_gross)}} zł</span><br><strong>{{'%.2f'|format(s.payable_gross)}} zł</strong></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 %}
|
||||
|
||||
+88
@@ -0,0 +1,88 @@
|
||||
from datetime import date, datetime
|
||||
from decimal import Decimal, InvalidOperation
|
||||
|
||||
from .extensions import db
|
||||
from .models import AppSetting
|
||||
|
||||
KEYS = {
|
||||
"enabled": "global_vat_override_enabled",
|
||||
"name": "global_vat_override_name",
|
||||
"rate": "global_vat_override_rate",
|
||||
"start": "global_vat_override_start",
|
||||
"end": "global_vat_override_end",
|
||||
}
|
||||
|
||||
|
||||
def _value(key, default=""):
|
||||
setting = db.session.get(AppSetting, KEYS[key])
|
||||
return setting.value if setting else default
|
||||
|
||||
|
||||
def global_vat_override():
|
||||
rate_raw = _value("rate")
|
||||
try:
|
||||
rate = Decimal(rate_raw) if rate_raw != "" else None
|
||||
except InvalidOperation:
|
||||
rate = None
|
||||
return {
|
||||
"enabled": _value("enabled", "0") == "1",
|
||||
"name": _value("name", "Projekt rządowy"),
|
||||
"rate": rate,
|
||||
"start": _parse_date(_value("start")),
|
||||
"end": _parse_date(_value("end")),
|
||||
}
|
||||
|
||||
|
||||
def _parse_date(value):
|
||||
if not value:
|
||||
return None
|
||||
try:
|
||||
return date.fromisoformat(value)
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
def effective_vat_rate(nominal_rate, on_date=None):
|
||||
override = global_vat_override()
|
||||
if not override["enabled"] or override["rate"] is None:
|
||||
return Decimal(str(nominal_rate))
|
||||
if isinstance(on_date, datetime):
|
||||
on_date = on_date.date()
|
||||
on_date = on_date or date.today()
|
||||
if override["start"] and on_date < override["start"]:
|
||||
return Decimal(str(nominal_rate))
|
||||
if override["end"] and on_date > override["end"]:
|
||||
return Decimal(str(nominal_rate))
|
||||
return override["rate"]
|
||||
|
||||
|
||||
def save_global_vat_override(form):
|
||||
enabled = "enabled" in form
|
||||
name = (form.get("name") or "Projekt rządowy").strip()
|
||||
rate_raw = (form.get("rate") or "").strip()
|
||||
start_raw = (form.get("start") or "").strip()
|
||||
end_raw = (form.get("end") or "").strip()
|
||||
|
||||
if enabled:
|
||||
try:
|
||||
rate = Decimal(rate_raw)
|
||||
except InvalidOperation as exc:
|
||||
raise ValueError("Podaj prawidłową globalną stawkę VAT.") from exc
|
||||
if rate < 0 or rate > 100:
|
||||
raise ValueError("Globalna stawka VAT musi mieścić się w zakresie 0–100%.")
|
||||
if start_raw and not _parse_date(start_raw):
|
||||
raise ValueError("Nieprawidłowa data rozpoczęcia.")
|
||||
if end_raw and not _parse_date(end_raw):
|
||||
raise ValueError("Nieprawidłowa data zakończenia.")
|
||||
if start_raw and end_raw and start_raw > end_raw:
|
||||
raise ValueError("Data zakończenia nie może być wcześniejsza niż rozpoczęcia.")
|
||||
|
||||
values = {
|
||||
KEYS["enabled"]: "1" if enabled else "0",
|
||||
KEYS["name"]: name,
|
||||
KEYS["rate"]: rate_raw,
|
||||
KEYS["start"]: start_raw,
|
||||
KEYS["end"]: end_raw,
|
||||
}
|
||||
for key, value in values.items():
|
||||
db.session.merge(AppSetting(key=key, value=value))
|
||||
@@ -128,3 +128,38 @@ def test_migration_runner_creates_registry_without_legacy_migrations(tmp_path):
|
||||
assert migrate_database() == []
|
||||
assert "schema_migration" in inspect(db.engine).get_table_names()
|
||||
assert migrate_database() == []
|
||||
|
||||
def test_global_vat_override_changes_50_percent_deduction(tmp_path):
|
||||
from decimal import Decimal
|
||||
from datetime import date
|
||||
from app.extensions import db
|
||||
from app.models import AppSetting
|
||||
from app.vat import effective_vat_rate
|
||||
|
||||
app = make_app(tmp_path, "global-vat.db")
|
||||
with app.app_context():
|
||||
for key, value in {
|
||||
"global_vat_override_enabled": "1",
|
||||
"global_vat_override_name": "CPN",
|
||||
"global_vat_override_rate": "8",
|
||||
"global_vat_override_start": "2026-07-01",
|
||||
"global_vat_override_end": "2026-07-31",
|
||||
}.items():
|
||||
db.session.merge(AppSetting(key=key, value=value))
|
||||
db.session.commit()
|
||||
rate = effective_vat_rate(Decimal("23"), date(2026, 7, 15))
|
||||
result = calculate_costs(Decimal("108"), rate, 50)
|
||||
assert rate == Decimal("8")
|
||||
assert result["net"] == Decimal("100.00")
|
||||
assert result["vat"] == Decimal("8.00")
|
||||
assert result["deductible_vat"] == Decimal("4.00")
|
||||
assert effective_vat_rate(Decimal("23"), date(2026, 8, 1)) == Decimal("23")
|
||||
|
||||
|
||||
def test_only_admin_can_change_global_vat(tmp_path):
|
||||
app = make_app(tmp_path, "global-vat-auth.db")
|
||||
client = app.test_client()
|
||||
client.post('/login', data={'email':'admin@example.com','password':'admin123!'})
|
||||
response = client.post('/admin/global-vat', data={'enabled':'on','name':'CPN','rate':'8','start':'2026-07-01','end':'2026-07-31'}, headers={'X-Requested-With':'XMLHttpRequest'})
|
||||
assert response.status_code == 200
|
||||
assert response.json['ok'] is True
|
||||
|
||||
Reference in New Issue
Block a user