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:
@ -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
|
||||
|
||||
@ -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<Utc>`). 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)]
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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?;
|
||||
|
||||
@ -25,7 +25,7 @@ pub async fn belege_details_html(
|
||||
) -> Result<Html<String>, 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<chrono::Utc>) -> 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<chrono::Utc>, tz: chrono_tz::Tz) -> String {
|
||||
dt.with_timezone(&tz).format("%d.%m.%Y %H:%M").to_string()
|
||||
}
|
||||
|
||||
fn state_label(s: DeliveryState) -> &'static str {
|
||||
|
||||
@ -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?;
|
||||
|
||||
@ -34,6 +34,9 @@ pub struct AppState {
|
||||
pub list_my_tours_today: Arc<ListMyToursTodayUseCase>,
|
||||
pub sync_tour: Arc<SyncTourUseCase>,
|
||||
pub import_erp_tours: Arc<ImportErpToursUseCase>,
|
||||
/// 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<DevResyncToursUseCase>,
|
||||
/// DEV-ONLY: setzt eine einzelne Lieferung (per Belegnummer) zurück.
|
||||
|
||||
Reference in New Issue
Block a user