date.today() w Django: faktura z datą poprzedniego miesiąca
date.today() czyta strefę systemu, nie TIME_ZONE z Django. Zamieniłem 104 wywołania na timezone.localdate() i dodałem test, który pilnuje, żeby nie wróciły.
Opublikowano: 5 min czytania
Autor: Łukasz Głowacz, Full-Stack Engineer · Read in English

Spis treści
W BusiKM TIME_ZONE ustawiony jest na Europe/Warsaw, a mimo to część dat w dokumentach liczyła się w innej strefie. Winne było date.today(), które czyta strefę systemu operacyjnego i w ogóle nie patrzy na ustawienia Django. 7 października zamieniłem 104 takie wywołania w 32 plikach na timezone.localdate() i dodałem test, który nie pozwoli im wrócić.
Błąd, którego nie widać na własnym komputerze
Django z USE_TZ = True trzyma czas w UTC, a TIME_ZONE mówi, w jakiej strefie pokazywać i liczyć daty lokalne. timezone.localdate() korzysta z tego ustawienia. date.today() pochodzi ze standardowej biblioteki Pythona i bierze datę z zegara systemu, więc o TIME_ZONE nic nie wie.
Na moim komputerze w Warszawie obie funkcje zwracają to samo, więc błąd jest niewidoczny przy pisaniu kodu i w ręcznych testach. W kontenerze, który ma strefę UTC, daty różnią się przez kilka godzin w nocy: latem od północy do 2:00 czasu polskiego, zimą od północy do 1:00. W tym czasie w Polsce jest już następny dzień, a dla systemu wciąż poprzedni.

Kilka godzin w nocy brzmi niegroźnie, dopóki nie spojrzy się na to, co te daty znaczą. BusiKM wystawia faktury, liczy statusy dokumentów pojazdów i kierowców, decyduje, który miesiąc jest zamknięty do eksportu księgowego. W każdym z tych miejsc data to dzień kalendarzowy w Polsce, bo na takim operują przepisy.
Faktura w złym miesiącu
Najlepiej widać to przy fakturze za zlecenie. Data sprzedaży to element faktury z art. 106e ustawy o VAT, a termin płatności liczy się od niej w dniach kalendarzowych. Stary kod brał daty z dwóch źródeł i oba były złe:
today = date.today() # system time zone
sale_dt = self.completed_at.date() if self.completed_at else today # UTC date
due_dt = today + timedelta(days=int(self.payment_terms_days or 14))date.today() czyta strefę systemu, a completed_at.date() bierze datę z wartości zapisanej w UTC. Zlecenie zamknięte 1 maja o 01:00 czasu polskiego dostawało datę sprzedaży 30 kwietnia, czyli trafiało do innego miesiąca rozliczeniowego. Poprawka liczy obie daty w czasie polskim:
# Sale date and payment due date are calendar days in Polish time,
# because that is what the VAT act works with.
today = timezone.localdate()
sale_dt = timezone.localdate(self.completed_at) if self.completed_at else today
due_dt = today + timedelta(days=int(self.payment_terms_days or 14))Drugi wzorzec, datetime.date() na wartości z bazy, łatwo przeoczyć, bo nie zawiera słowa today. timezone.localdate(value) przelicza wartość do strefy z ustawień, zanim weźmie z niej datę.
Ten sam błąd siedział w statusie dokumentów firmy (ważny, wygasa w ciągu 30 dni, wygasł), w panelu zgodności, w generatorach PDF, w dacie wystawienia przy imporcie z KSeF, gdy odpowiedź jej nie zawierała, i w sprawdzaniu, czy miesiąc jest już zamknięty i można go wyeksportować do systemu księgowego. Najwięcej wywołań, 37, było w skrypcie danych demo, który nie dotyka dokumentów klienta. Zamieniłem je razem z resztą, żeby w kodzie nie zostało żadne date.today(), na które ktoś mógłby się kiedyś powołać.
Jak przetestować błąd, który pojawia się w nocy
Test, który sprawdza datę o 12:00 w Warszawie, przejdzie zarówno ze starym, jak i z nowym kodem. Żeby test w ogóle rozróżniał obie funkcje, musi trafić w okno, w którym daty się różnią. Można zamrozić zegar na 00:30 czasu polskiego, ale wtedy test zależy od tego, w jakiej strefie działa maszyna, na której się uruchamia.
Zamiast tego test przestawia strefę aplikacji na Pacific/Kiritimati, czyli UTC+14, najdalej wysuniętą strefę świata, i zamraża zegar na 12:00 UTC. Data lokalna to wtedy już następny dzień, niezależnie od strefy maszyny, na której działa CI:
PROBE_TZ = "Pacific/Kiritimati" # UTC+14
FROZEN_UTC_NOON = "2026-04-26 12:00:00+00:00"
@freeze_time(FROZEN_UTC_NOON)
def test_local_date_is_a_day_ahead_of_utc(probe_timezone):
# The probe must actually separate the two dates, otherwise it tests nothing.
assert timezone.now().date() == date(2026, 4, 26)
assert timezone.localdate() == date(2026, 4, 27)Pierwszy test w pliku sprawdza samą sondę: jeśli kiedyś przestanie rozdzielać obie daty, kolejne testy faktur i okresów rozliczeniowych nie testowałyby niczego. Następne sprawdzają już konkretne miejsca, na przykład że data sprzedaży na fakturze równa się timezone.localdate(), a nie dacie z UTC.
Strażnik, który nie wpuści date.today() z powrotem
Sto wywołań nie pojawiło się przez jeden błąd. To nawyk: date.today() jest pierwszym, co przychodzi do głowy, także modelom AI, z którymi piszę większość kodu. Poprawienie wszystkich miejsc nie wystarczy, jeśli następne pojawi się za tydzień.
Dlatego w tym samym pliku jest test, który przechodzi po kodzie aplikacji i szuka gołego date.today() poza testami i migracjami:
def test_application_code_has_no_bare_date_today():
apps_dir = Path(__file__).resolve().parents[2]
bare_call = re.compile(r"(?<![\w.])date\.today\(\)")
offenders = []
for path in apps_dir.rglob("*.py"):
if "tests" in path.parts or "migrations" in path.parts:
continue
for number, line in enumerate(path.read_text(encoding="utf-8").splitlines(), start=1):
if not line.lstrip().startswith("#") and bare_call.search(line):
offenders.append(f"{path.relative_to(apps_dir.parent)}:{number}")
assert offenders == [], "Use timezone.localdate(): " + ", ".join(offenders)To prosty grep zapisany jako test, a nie reguła lintera. Wybrałem test, bo uruchamia się w tym samym miejscu co reszta testów, komunikat od razu mówi, czego użyć, a lista wyjątków (testy, migracje) jest widoczna obok reguły. Jego ograniczenie jest jasne: nie złapie datetime.now().date() ani completed_at.date(), czyli drugiego wzorca z faktury. Te miejsca pilnują testy z sondą UTC+14, ale tylko tam, gdzie je napisałem.
Co z tego zostaje
Dokumenty, które BusiKM generuje, są dokumentami księgowymi, więc data na nich to nie szczegół wyświetlania. Ewidencja przebiegu musi być przechowywana pięć lat, faktura z datą sprzedaży w złym miesiącu to korekta. Błąd strefy czasowej w takim systemie jest błędem merytorycznym, nawet jeśli dotyczy dwóch godzin w nocy.
Najbardziej przydatna okazała się sonda UTC+14. Zamienia błąd, który pojawia się tylko w nocy i tylko w kontenerze, w taki, który widać w każdym przebiegu testów na każdej maszynie. Jeśli też liczysz w Django daty dokumentów i chcesz porozmawiać o tym, jak to sprawdzać, napisz do mnie.
