fix(time): konfigurierbare Fach-Zeitzone statt UTC/OS-Local fuer alle Datumsangaben
Lieferbericht zeigte UTC (08.09. 22:54 statt 09.09. 00:54): renderer.dt() formatierte DateTime<Utc> ohne Umrechnung. Weitere Stellen hingen an chrono::Local (OS-Zone des Hosts) bzw. hardcodiertem 'Europe/Berlin'. Neu: server.timezone (IANA, Default Europe/Berlin, chrono-tz) und durchgaengig UTC-Instant -> konfigurierte Zone: - Lieferbericht: alle Zeitstempel (Abgeschlossen, Notizen, Scans, erzeugt am) - Belegansicht (fmt_datetime) - ERP-Rueckschreibung delivered_at (Wanduhrzeit) - 'heute'-Ableitung: /me/tours, /dev/resync, /admin/import-erp, Import-Cron + Startup-Catch-up - Completion-Repo: AT TIME ZONE als Bind-Param statt Literal Speicherung bleibt UTC (TIMESTAMPTZ / DateTime<Utc>) - nur Anzeige und Kalendertag-Ableitung nutzen die Zone. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@ -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
|
||||
|
||||
@ -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<String> = 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)?;
|
||||
|
||||
@ -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: std::fmt::Display>(e: E) -> ApplicationError {
|
||||
ApplicationError::Repository(format!("pdf: {e}"))
|
||||
}
|
||||
|
||||
fn dt(d: &chrono::DateTime<chrono::Utc>) -> 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<chrono::Utc>) -> String {
|
||||
d.with_timezone(&tz).format("%d.%m.%Y %H:%M").to_string()
|
||||
}
|
||||
fn opt(s: &Option<String>) -> &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,
|
||||
|
||||
Reference in New Issue
Block a user