namespace SQLInsight.Core.DynamicsAx; /// /// Axapta 3.0 and AX 4.0 store batch and cleanup times as a local date plus seconds since midnight, in the time of the /// machine that wrote them (the AOS, or the batch client in a 2-tier installation). They become UTC with the rules of /// that time zone on that date, so a time from before a change between summer and winter time is not an hour off. /// /// The zone is SQLInsight:DynamicsAx:LocalTimeZone when set (a Windows or IANA id), else the agent's own zone when /// its offset right now is the SQL Server's (the usual case: agent, SQL Server and AOS in one country). /// Otherwise the SQL Server's current offset is used for every time, with a note that times from the other side of a /// change can be an hour off. /// A local time that does not exist (the hour skipped in spring) or exists twice (the hour repeated in autumn) gets /// the zone's standard offset: deterministic, never an exception, and in autumn the later of the two moments, so a job /// is never reported late because of the repeated hour. /// /// public static class AxLocalTime { /// The setting that names the AX time zone. public const string Setting = "SQLInsight:DynamicsAx:LocalTimeZone"; /// /// A local AX date and seconds since midnight as UTC. AX's empty date (1900-01-01 or earlier) and seconds outside /// 0..86400 are unknown (null); 86400 is midnight at the end of the day. /// /// DATEDIFF(minute, GETDATE(), GETUTCDATE()) on the SQL Server when read (UTC − local). public static DateTime? ToUtc(DateTime? date, int? seconds, int serverOffsetMinutes, TimeZoneInfo? zone) { if (date is not { } d || d.Year <= 1900 || seconds is < 0 or > 86_400) { return null; } var local = DateTime.SpecifyKind(d.Date.AddSeconds(seconds ?? 0), DateTimeKind.Unspecified); var offset = zone is null ? TimeSpan.FromMinutes(-RoundedOffset(serverOffsetMinutes)) : zone.GetUtcOffset(local); return DateTime.SpecifyKind(local - offset, DateTimeKind.Utc); } /// /// The SQL Server's offset to the nearest quarter of an hour: GETDATE() and GETUTCDATE() are two calls, and /// DATEDIFF(minute) counts minute boundaries, so the raw value can be a minute off. /// public static int RoundedOffset(int minutes) => (int)Math.Round(minutes / 15.0, MidpointRounding.AwayFromZero) * 15; /// /// The zone for a database's local AX times, or null to use the SQL Server's current offset, and a note (Danish) when /// that is less exact than it could be. /// /// SQLInsight:DynamicsAx:LocalTimeZone. /// The SQL Server's offset (UTC − local) when read, or null when nothing was read. /// The agent's own zone (TimeZoneInfo.Local; a parameter for tests). public static (TimeZoneInfo? Zone, string? Note) Resolve(string? configured, int? serverOffsetMinutes, DateTime nowUtc, TimeZoneInfo? agentZone = null) { if (!string.IsNullOrWhiteSpace(configured)) { return TimeZoneInfo.TryFindSystemTimeZoneById(configured.Trim(), out var zone) ? (zone, null) : (null, $"Tidszonen \"{configured.Trim()}\" ({Setting}) findes ikke på agentens server. AX' lokale tider omregnes i stedet med SQL Servers " + "nuværende forskel til UTC, så tider fra før et skift mellem sommer- og vintertid kan være en time forkerte."); } agentZone ??= TimeZoneInfo.Local; var now = DateTime.SpecifyKind(nowUtc, DateTimeKind.Utc); if (serverOffsetMinutes is not { } server || -(int)Math.Round(agentZone.GetUtcOffset(now).TotalMinutes) == RoundedOffset(server)) { return (agentZone, null); } return (null, $"AX' lokale tider (Axapta 3.0/4.0) omregnes med SQL Servers nuværende forskel til UTC, fordi agentens tidszone ({agentZone.Id}) " + $"har en anden forskel lige nu. Tider fra før et skift mellem sommer- og vintertid kan derfor være en time forkerte. Sæt {Setting} " + "til AOS'ens tidszone for at få dem helt rigtige."); } }