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