diff --git a/Cargo.lock b/Cargo.lock index ca71d94..e33cd7c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -427,6 +427,17 @@ dependencies = [ "windows-link", ] +[[package]] +name = "chrono-tz" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6139a8597ed92cf816dfb33f5dd6cf0bb93a6adc938f11039f371bc5bcd26c3" +dependencies = [ + "chrono", + "phf", + "serde", +] + [[package]] name = "color_quant" version = "1.1.0" @@ -1018,6 +1029,7 @@ dependencies = [ "askama", "axum", "chrono", + "chrono-tz", "holzleitner-application", "holzleitner-domain", "holzleitner-infrastructure", @@ -1045,6 +1057,7 @@ version = "0.1.0" dependencies = [ "async-trait", "chrono", + "chrono-tz", "holzleitner-domain", "imagesize", "serde", @@ -1070,6 +1083,7 @@ version = "0.1.0" dependencies = [ "async-trait", "chrono", + "chrono-tz", "holzleitner-application", "holzleitner-domain", "image", @@ -1931,6 +1945,24 @@ version = "2.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" +[[package]] +name = "phf" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "913273894cec178f401a31ec4b656318d95473527be05c0752cc41cdc32be8b7" +dependencies = [ + "phf_shared", +] + +[[package]] +name = "phf_shared" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06005508882fb681fd97892ecff4b7fd0fee13ef1aa569f8695dae7ab9099981" +dependencies = [ + "siphasher", +] + [[package]] name = "pin-project-lite" version = "0.2.17" @@ -2777,6 +2809,12 @@ dependencies = [ "time", ] +[[package]] +name = "siphasher" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649" + [[package]] name = "slab" version = "0.4.12" diff --git a/Cargo.toml b/Cargo.toml index 8b3fd84..d9fff92 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -19,6 +19,7 @@ serde = { version = "1", features = ["derive"] } serde_json = "1" uuid = { version = "1", features = ["serde", "v4"] } chrono = { version = "0.4", default-features = false, features = ["serde", "clock"] } +chrono-tz = { version = "0.10", features = ["serde"] } async-trait = "0.1" thiserror = "2" tokio = { version = "1", features = ["full"] } diff --git a/crates/api/Cargo.toml b/crates/api/Cargo.toml index 13671ef..c735ae2 100644 --- a/crates/api/Cargo.toml +++ b/crates/api/Cargo.toml @@ -26,6 +26,7 @@ serde.workspace = true serde_json.workspace = true uuid.workspace = true chrono.workspace = true +chrono-tz.workspace = true thiserror.workspace = true anyhow.workspace = true toml.workspace = true diff --git a/crates/api/src/config.rs b/crates/api/src/config.rs index f37355b..f8ee9a6 100644 --- a/crates/api/src/config.rs +++ b/crates/api/src/config.rs @@ -127,6 +127,10 @@ impl Config { ), None => tracing::info!("cfg.dev.today_override = (echte Uhr)"), } + tracing::info!( + timezone = self.server.timezone.name(), + "cfg.server.timezone (für 'heute'-Ableitung + Anzeige von UTC-Zeitstempeln)" + ); if self.dev.sync_enabled { tracing::warn!( "cfg.DEV: dev.sync_enabled AKTIV — ungeschützter Endpoint \ @@ -374,6 +378,21 @@ fn default_report_retry_cron() -> String { pub struct ServerConfig { pub host: String, pub port: u16, + /// Fach-/Anzeige-Zeitzone (IANA-Name, z. B. `Europe/Berlin`). + /// + /// Zeitstempel werden durchgängig als UTC gespeichert (`TIMESTAMPTZ`, + /// `DateTime`). Diese Zone gilt für alles, was ein **Kalenderdatum** + /// oder eine **Wanduhrzeit** braucht: „heute" für Touren/Import-Cron, + /// Datumsangaben im Lieferbericht und in der Belegansicht sowie die + /// ERP-Rückschreibung (`delivered_at`). Bewusst konfiguriert statt der + /// OS-Zeitzone des Servers (`chrono::Local`), damit das Verhalten nicht + /// von der Systemeinstellung des Hosts abhängt. + #[serde(default = "default_timezone")] + pub timezone: chrono_tz::Tz, +} + +fn default_timezone() -> chrono_tz::Tz { + chrono_tz::Europe::Berlin } #[derive(Debug, Clone, Deserialize)] diff --git a/crates/api/src/main.rs b/crates/api/src/main.rs index 244d964..bcca54f 100644 --- a/crates/api/src/main.rs +++ b/crates/api/src/main.rs @@ -215,8 +215,10 @@ pub(crate) async fn run_app( let car_repository = Arc::new(PgCarRepository::new(pool.clone())); let payment_method_repository = Arc::new(PgPaymentMethodRepository::new(pool.clone())); let delivery_credit_repository = Arc::new(PgDeliveryCreditRepository::new(pool.clone())); - let delivery_completion_repository = - Arc::new(PgDeliveryCompletionRepository::new(pool.clone())); + let delivery_completion_repository = Arc::new(PgDeliveryCompletionRepository::new( + pool.clone(), + cfg.server.timezone, + )); let service_repository = Arc::new(PgServiceRepository::new(pool.clone())); let delivery_service_repository = Arc::new(PgDeliveryServiceRepository::new(pool.clone())); let report_repository = Arc::new(PgDeliveryReportRepository::new(pool.clone())); @@ -246,7 +248,7 @@ pub(crate) async fn run_app( ); let generate_delivery_report = Arc::new(GenerateDeliveryReportUseCase::new( report_repository, - Arc::new(PdfDeliveryReportRenderer), + Arc::new(PdfDeliveryReportRenderer::new(cfg.server.timezone)), report_sink.clone(), signature_storage.clone(), attachment_storage.clone(), @@ -301,6 +303,7 @@ pub(crate) async fn run_app( let list_my_tours_today = Arc::new(ListMyToursTodayUseCase::new( tour_repository.clone(), cfg.dev.today_override, + cfg.server.timezone, )); let sync_tour = Arc::new(SyncTourUseCase::new(tour_repository.clone())); // ERP-Import (täglicher Pull aus ERPframe-MSSQL) — nutzt denselben @@ -465,6 +468,7 @@ pub(crate) async fn run_app( sync_tour, set_delivery_order, import_erp_tours: import_erp_tours.clone(), + timezone: cfg.server.timezone, dev_resync_tours, dev_reset_delivery, generate_delivery_report, @@ -556,10 +560,14 @@ pub(crate) async fn run_app( .context("JobScheduler konnte nicht erstellt werden")?; let import = import_erp_tours.clone(); let offset = cfg.import.date_offset_days; + let tz = cfg.server.timezone; let job = Job::new_async(cfg.import.cron.as_str(), move |_uuid, _lock| { let import = import.clone(); Box::pin(async move { - let date = (chrono::Local::now() + chrono::Duration::days(offset)).date_naive(); + // Kalendertag in der konfigurierten Zone (nicht OS-Local). + let date = (chrono::Utc::now().with_timezone(&tz) + + chrono::Duration::days(offset)) + .date_naive(); match import.execute_with(date, SyncTrigger::Scheduler).await { Ok(summary) => tracing::info!( date = %summary.date, @@ -591,8 +599,11 @@ pub(crate) async fn run_app( let import = import_erp_tours.clone(); let sync_runs = sync_run_repository.clone(); let offset = cfg.import.date_offset_days; + let tz = cfg.server.timezone; tokio::spawn(async move { - let date = (chrono::Local::now() + chrono::Duration::days(offset)).date_naive(); + let date = (chrono::Utc::now().with_timezone(&tz) + + chrono::Duration::days(offset)) + .date_naive(); match sync_runs.has_successful_run_for(date).await { Ok(true) => tracing::info!( %date, diff --git a/crates/api/src/routes/admin.rs b/crates/api/src/routes/admin.rs index cb60e45..3f62634 100644 --- a/crates/api/src/routes/admin.rs +++ b/crates/api/src/routes/admin.rs @@ -82,7 +82,9 @@ pub async fn import_erp( "ungültiges Datum '{s}' (erwartet YYYY-MM-DD): {e}" ))) })?, - None => chrono::Local::now().date_naive(), + None => chrono::Utc::now() + .with_timezone(&state.timezone) + .date_naive(), }; tracing::info!(%date, "admin.import_erp"); let summary = state.import_erp_tours.execute(date).await?; diff --git a/crates/api/src/routes/belege_view.rs b/crates/api/src/routes/belege_view.rs index 9078002..cb8591e 100644 --- a/crates/api/src/routes/belege_view.rs +++ b/crates/api/src/routes/belege_view.rs @@ -25,7 +25,7 @@ pub async fn belege_details_html( ) -> Result, ApiError> { tracing::info!(%belegnummer, "admin.belege_details_html"); let details = state.get_delivery_details.execute(&belegnummer).await?; - let page = build_page(details); + let page = build_page(details, state.timezone); let html = page.render().map_err(|e| { ApiError(ApplicationError::Unexpected(format!( "template-render fehlgeschlagen: {e}" @@ -112,7 +112,7 @@ struct ContactSourceVm { // ===== Assembly ============================================================ -fn build_page(d: DeliveryDetails) -> DeliveryDetailsPage { +fn build_page(d: DeliveryDetails, tz: chrono_tz::Tz) -> DeliveryDetailsPage { let delivery = &d.delivery.delivery; // Positionen: Artikel-/Lagernamen auflösen, Mengen/Preise formatieren. @@ -171,7 +171,7 @@ fn build_page(d: DeliveryDetails) -> DeliveryDetailsPage { .map(|n| NoteVm { text: n.text.clone().unwrap_or_default(), author: n.author_personalnummer, - created_at: fmt_datetime(n.created_at), + created_at: fmt_datetime(n.created_at, tz), credit_note: n.is_amount_credit_note || n.credit_delivery_item_id.is_some(), image: n.image_attachment.is_some(), }) @@ -253,7 +253,7 @@ fn build_page(d: DeliveryDetails) -> DeliveryDetailsPage { services, notes, contacts, - generated_at: fmt_datetime(chrono::Utc::now()), + generated_at: fmt_datetime(chrono::Utc::now(), tz), } } @@ -311,10 +311,10 @@ fn fmt_date(d: chrono::NaiveDate) -> String { d.format("%d.%m.%Y").to_string() } -fn fmt_datetime(dt: chrono::DateTime) -> String { - dt.with_timezone(&chrono::Local) - .format("%d.%m.%Y %H:%M") - .to_string() +/// UTC-Zeitstempel in der konfigurierten Zone (`server.timezone`) als +/// `TT.MM.JJJJ HH:MM` — bewusst nicht `chrono::Local` (OS-Zone des Hosts). +fn fmt_datetime(dt: chrono::DateTime, tz: chrono_tz::Tz) -> String { + dt.with_timezone(&tz).format("%d.%m.%Y %H:%M").to_string() } fn state_label(s: DeliveryState) -> &'static str { diff --git a/crates/api/src/routes/dev.rs b/crates/api/src/routes/dev.rs index e6ecf6c..9a1cdb1 100644 --- a/crates/api/src/routes/dev.rs +++ b/crates/api/src/routes/dev.rs @@ -50,7 +50,9 @@ pub async fn dev_resync( "ungültiges Datum '{s}' (erwartet YYYY-MM-DD): {e}" ))) })?, - None => chrono::Local::now().date_naive(), + None => chrono::Utc::now() + .with_timezone(&state.timezone) + .date_naive(), }; tracing::warn!(%date, "dev.resync: Postgres wird überschrieben + neu importiert"); let summary = state.dev_resync_tours.execute(date).await?; diff --git a/crates/api/src/state.rs b/crates/api/src/state.rs index 9516900..b1d6a66 100644 --- a/crates/api/src/state.rs +++ b/crates/api/src/state.rs @@ -34,6 +34,9 @@ pub struct AppState { pub list_my_tours_today: Arc, pub sync_tour: Arc, pub import_erp_tours: Arc, + /// Konfigurierte Fach-/Anzeige-Zeitzone (`server.timezone`) — für + /// „heute"-Ableitungen in Handlern und die Darstellung von UTC-Zeitstempeln. + pub timezone: chrono_tz::Tz, /// DEV-ONLY: überschreibender Resync (löscht Postgres + importiert neu). pub dev_resync_tours: Arc, /// DEV-ONLY: setzt eine einzelne Lieferung (per Belegnummer) zurück. diff --git a/crates/application/Cargo.toml b/crates/application/Cargo.toml index 2fb301c..c753cb2 100644 --- a/crates/application/Cargo.toml +++ b/crates/application/Cargo.toml @@ -17,6 +17,7 @@ async-trait.workspace = true thiserror.workspace = true uuid.workspace = true chrono.workspace = true +chrono-tz.workspace = true sha2.workspace = true imagesize.workspace = true utoipa = { workspace = true, optional = true } diff --git a/crates/application/src/usecases/list_my_tours_today.rs b/crates/application/src/usecases/list_my_tours_today.rs index 9ae8d14..b0758a0 100644 --- a/crates/application/src/usecases/list_my_tours_today.rs +++ b/crates/application/src/usecases/list_my_tours_today.rs @@ -1,6 +1,7 @@ use std::sync::Arc; -use chrono::{Local, NaiveDate}; +use chrono::{NaiveDate, Utc}; +use chrono_tz::Tz; use crate::dto::TourSummary; use crate::error::ApplicationError; @@ -22,13 +23,20 @@ use crate::ports::TourRepository; pub struct ListMyToursTodayUseCase { repository: Arc, today_override: Option, + /// Fach-Zeitzone (`server.timezone`), in der „heute" bestimmt wird. + tz: Tz, } impl ListMyToursTodayUseCase { - pub fn new(repository: Arc, today_override: Option) -> Self { + pub fn new( + repository: Arc, + today_override: Option, + tz: Tz, + ) -> Self { Self { repository, today_override, + tz, } } @@ -37,14 +45,15 @@ impl ListMyToursTodayUseCase { personalnummer: i64, requested_date: Option, ) -> Result, ApplicationError> { - // Bewusst Local statt Utc: der Tagesumschlag muss sich nach der - // Ortszeit des Fahrers richten. Mit Utc wechselt "heute" erst um - // 02:00 Ortszeit (CEST = UTC+2) — bis dahin sähe der Fahrer nach - // Mitternacht noch die Touren von gestern. Der Server läuft in - // Europe/Vienna, daher ist Local hier korrekt. + // Der Tagesumschlag muss sich nach der Ortszeit des Fahrers richten: + // mit reinem Utc wechselte "heute" erst um 02:00 Ortszeit (CEST = + // UTC+2) — bis dahin sähe der Fahrer nach Mitternacht noch die Touren + // von gestern. Daher der UTC-Instant → konfigurierte Fach-Zeitzone + // (`server.timezone`) → Kalendertag. Bewusst NICHT `chrono::Local`: + // das wäre die OS-Zone des Hosts und damit einstellungsabhängig. let date = requested_date .or(self.today_override) - .unwrap_or_else(|| Local::now().date_naive()); + .unwrap_or_else(|| Utc::now().with_timezone(&self.tz).date_naive()); self.repository .find_today_for_driver(personalnummer, date) .await diff --git a/crates/infrastructure/Cargo.toml b/crates/infrastructure/Cargo.toml index e50aebe..00bf8b8 100644 --- a/crates/infrastructure/Cargo.toml +++ b/crates/infrastructure/Cargo.toml @@ -14,6 +14,7 @@ serde.workspace = true serde_json.workspace = true uuid.workspace = true chrono.workspace = true +chrono-tz.workspace = true tokio.workspace = true tokio-util.workspace = true sqlx.workspace = true diff --git a/crates/infrastructure/src/persistence/delivery_completion_repository.rs b/crates/infrastructure/src/persistence/delivery_completion_repository.rs index 9b69de9..e2be472 100644 --- a/crates/infrastructure/src/persistence/delivery_completion_repository.rs +++ b/crates/infrastructure/src/persistence/delivery_completion_repository.rs @@ -21,11 +21,15 @@ use holzleitner_domain::{Address, Delivery, DeliveryState}; pub struct PgDeliveryCompletionRepository { pool: PgPool, + /// Fach-Zeitzone (`server.timezone`) für Kalendertag-Filter auf + /// `completed_at` (TIMESTAMPTZ) und die ERP-Rückschreibung als lokale + /// Wanduhrzeit — statt hardcodiertem 'Europe/Berlin' bzw. OS-`Local`. + tz: chrono_tz::Tz, } impl PgDeliveryCompletionRepository { - pub fn new(pool: PgPool) -> Self { - Self { pool } + pub fn new(pool: PgPool, tz: chrono_tz::Tz) -> Self { + Self { pool, tz } } } @@ -434,9 +438,9 @@ impl DeliveryCompletionRepository for PgDeliveryCompletionRepository { Ok(ErpWritebackData { belegart_id, belegnummer, - // ERP erwartet lokale Zeit; completed_at ist UTC → in lokale - // Wanduhrzeit umrechnen und die TZ-Info fallenlassen. - delivered_at: completed_at.with_timezone(&chrono::Local).naive_local(), + // ERP erwartet lokale Zeit; completed_at ist UTC → in die + // konfigurierte Fach-Zeitzone umrechnen und die TZ-Info fallenlassen. + delivered_at: completed_at.with_timezone(&self.tz).naive_local(), lines, credit_amount_cents, payment_method_code, @@ -450,10 +454,10 @@ impl DeliveryCompletionRepository for PgDeliveryCompletionRepository { // INNER JOIN auf delivery_completions ⇒ nur ausgelieferte (abgeschlossene) // Lieferungen. `mail_sent_at IS NULL` ⇒ nur noch nicht versendete // (server-seitiges Dedup für den Mailclient). Der optionale Tagesfilter: - // bei NULL ($1) ⇒ ALLE offenen über alle Tage; sonst der Berliner - // Kalendertag von completed_at (TIMESTAMPTZ = UTC-Instant → AT TIME ZONE - // 'Europe/Berlin' → ::date), damit ein Abschluss um 23:30 Ortszeit nicht - // fälschlich dem UTC-Folgetag zugeordnet wird. + // bei NULL ($1) ⇒ ALLE offenen über alle Tage; sonst der Kalendertag + // von completed_at in der konfigurierten Fach-Zeitzone ($2, TIMESTAMPTZ = + // UTC-Instant → AT TIME ZONE → ::date), damit ein Abschluss um 23:30 + // Ortszeit nicht fälschlich dem UTC-Folgetag zugeordnet wird. let belegnummern: Vec = sqlx::query_scalar( r#" SELECT d.erp_belegnummer @@ -461,11 +465,12 @@ impl DeliveryCompletionRepository for PgDeliveryCompletionRepository { JOIN delivery_completions c ON c.delivery_id = d.id WHERE c.mail_sent_at IS NULL AND ( $1::date IS NULL - OR (c.completed_at AT TIME ZONE 'Europe/Berlin')::date = $1 ) + OR (c.completed_at AT TIME ZONE $2::text)::date = $1 ) ORDER BY c.completed_at "#, ) .bind(day) + .bind(self.tz.name()) .fetch_all(&self.pool) .await .map_err(db)?; @@ -509,14 +514,15 @@ impl DeliveryCompletionRepository for PgDeliveryCompletionRepository { FROM deliveries d JOIN delivery_completions c ON c.delivery_id = d.id WHERE ( $1::date IS NULL - OR (c.completed_at AT TIME ZONE 'Europe/Berlin')::date >= $1 ) + OR (c.completed_at AT TIME ZONE $3::text)::date >= $1 ) AND ( $2::date IS NULL - OR (c.completed_at AT TIME ZONE 'Europe/Berlin')::date <= $2 ) + OR (c.completed_at AT TIME ZONE $3::text)::date <= $2 ) ORDER BY c.completed_at "#, ) .bind(from) .bind(to) + .bind(self.tz.name()) .fetch_all(&self.pool) .await .map_err(db)?; diff --git a/crates/infrastructure/src/report/renderer.rs b/crates/infrastructure/src/report/renderer.rs index 1ca591c..26288c9 100644 --- a/crates/infrastructure/src/report/renderer.rs +++ b/crates/infrastructure/src/report/renderer.rs @@ -41,7 +41,19 @@ fn cents(c: i64) -> String { money(c as f64 / 100.0) } -pub struct PdfDeliveryReportRenderer; +/// Rendert den Lieferbericht als PDF. Alle Zeitstempel in den Report-Daten +/// sind UTC; für die Darstellung werden sie in die konfigurierte Fach-Zeitzone +/// (`server.timezone`, z. B. Europe/Berlin) umgerechnet — sonst stünde z. B. +/// „08.09. 22:54" statt „09.09. 00:54" im Bericht. +pub struct PdfDeliveryReportRenderer { + tz: chrono_tz::Tz, +} + +impl PdfDeliveryReportRenderer { + pub fn new(tz: chrono_tz::Tz) -> Self { + Self { tz } + } +} struct Pdf { doc: PdfDocumentReference, @@ -322,8 +334,9 @@ fn ext(e: E) -> ApplicationError { ApplicationError::Repository(format!("pdf: {e}")) } -fn dt(d: &chrono::DateTime) -> String { - d.format("%d.%m.%Y %H:%M").to_string() +/// UTC-Instant → Wanduhrzeit in der konfigurierten Zone, `TT.MM.JJJJ HH:MM`. +fn dt(tz: chrono_tz::Tz, d: &chrono::DateTime) -> String { + d.with_timezone(&tz).format("%d.%m.%Y %H:%M").to_string() } fn opt(s: &Option) -> &str { s.as_deref().unwrap_or("—") @@ -405,12 +418,12 @@ impl DeliveryReportRenderer for PdfDeliveryReportRenderer { p.kv("Status", state_de(&d.state)); p.kv("Tourdatum", &d.tour_date.format("%d.%m.%Y").to_string()); match &d.completion { - Some(c) => p.kv("Abgeschlossen", &dt(&c.completed_at)), + Some(c) => p.kv("Abgeschlossen", &dt(self.tz, &c.completed_at)), None => p.kv("Abgeschlossen", "— (nicht abgeschlossen)"), } p.kv("Fahrer", &format!("{} ({})", d.driver_name, d.driver_personalnummer)); p.kv("Fahrzeug", opt(&d.car_plate)); - p.kv("Erstellt am", &dt(&d.generated_at)); + p.kv("Erstellt am", &dt(self.tz, &d.generated_at)); // 2. Kunde & Lieferadresse p.heading("Kunde & Lieferadresse"); @@ -506,7 +519,7 @@ impl DeliveryReportRenderer for PdfDeliveryReportRenderer { if let Some(collected) = c.collected_amount_cents { p.kv( "Betrag erhalten", - &format!("Ja — {} (am {})", cents(collected), dt(&c.completed_at)), + &format!("Ja — {} (am {})", cents(collected), dt(self.tz, &c.completed_at)), ); } } @@ -542,7 +555,7 @@ impl DeliveryReportRenderer for PdfDeliveryReportRenderer { p.text("— keine —", 9.0, false, 0.0); } else { for n in &d.notes { - let mut head = format!("{} · Fahrer {}", dt(&n.created_at), n.author_personalnummer); + let mut head = format!("{} · Fahrer {}", dt(self.tz, &n.created_at), n.author_personalnummer); if n.is_amount_credit_note { head.push_str(" · [Gutschrift-Notiz]"); } @@ -564,7 +577,7 @@ impl DeliveryReportRenderer for PdfDeliveryReportRenderer { Some(c) => { // Es gibt nur EINEN Abschlusszeitpunkt (completed_at) — er gilt // für beide Bestätigungen und wird hier neben den Häkchen gezeigt. - let bestätigt_am = dt(&c.completed_at); + let bestätigt_am = dt(self.tz, &c.completed_at); let receipt = if c.receipt_confirmed { format!("Ja (am {bestätigt_am})") } else { @@ -615,7 +628,7 @@ impl DeliveryReportRenderer for PdfDeliveryReportRenderer { } p.row( &[ - (dt(&s.server_recorded_at), 30.0, false), + (dt(self.tz, &s.server_recorded_at), 30.0, false), (action, 20.0, false), (art, 58.0, false), (format!("{:+}", s.delta), 10.0, false), @@ -652,7 +665,7 @@ impl DeliveryReportRenderer for PdfDeliveryReportRenderer { for c in &d.credit_audit { p.row( &[ - (dt(&c.recorded_at), 34.0, false), + (dt(self.tz, &c.recorded_at), 34.0, false), (credit_action_de(&c.action).to_string(), 22.0, false), (cents(c.amount_cents), 26.0, false), (c.author_personalnummer.to_string(), 20.0, false), @@ -674,7 +687,7 @@ impl DeliveryReportRenderer for PdfDeliveryReportRenderer { a.filename.clone().unwrap_or_else(|| "(ohne Name)".into()), a.mime_type, a.size_bytes / 1024, - dt(&a.uploaded_at), + dt(self.tz, &a.uploaded_at), a.uploaded_by ); p.text(&meta, 8.0, true, 0.0); @@ -688,7 +701,7 @@ impl DeliveryReportRenderer for PdfDeliveryReportRenderer { // 11. Footer p.gap(4.0); p.text( - &format!("Automatisch erzeugt am {} — Holzleitner Auslieferung", dt(&d.generated_at)), + &format!("Automatisch erzeugt am {} — Holzleitner Auslieferung", dt(self.tz, &d.generated_at)), 7.0, false, 0.0,