sqlinsight-pro/src/SQLInsight.Core/DynamicsAx/AxLocalTime.cs
Søren Eggert Lundsteen Olsen 66ed4e11b3 Review 1.4: AX 3.0/4.0 local times in the AX time zone, with its DST rules
Axapta 3.0 and AX 4.0 store batch and cleanup times as a local date plus
seconds. They were turned into UTC with the SQL Server's offset at collection
time, so every time from the other side of a DST change was an hour off (a job
planned 01:50 CET on the spring night looked 70 minutes late at 03:00 CEST),
and a SQL Server running UTC behind a CET AOS shifted every time.

AxLocalTime converts with the rules of the AX time zone on that date:
SQLInsight:DynamicsAx:LocalTimeZone (Windows or IANA id), else the agent's own
zone when its offset matches the SQL Server's, else the server's offset (to the
nearest quarter hour) with a Danish data note. The skipped spring hour and the
repeated autumn hour get standard time (deterministic, never throws, never a
false "late"); seconds outside 0..86400 are unknown and 86400 is midnight. The
AX 3.0 cleanup time is local by version, also when LATESTCLEANTIME is missing.
AX 2009+ UTC times are untouched. Also: remaining numbers cannot overflow for
HIGHEST = Int64.MaxValue, and run keys use the invariant culture.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-02 14:19:17 +02:00

73 lines
4.4 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

namespace SQLInsight.Core.DynamicsAx;
/// <summary>
/// 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.
/// <list type="bullet">
/// <item>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).</item>
/// <item>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.</item>
/// <item>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.</item>
/// </list>
/// </summary>
public static class AxLocalTime
{
/// <summary>The setting that names the AX time zone.</summary>
public const string Setting = "SQLInsight:DynamicsAx:LocalTimeZone";
/// <summary>
/// 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.
/// </summary>
/// <param name="serverOffsetMinutes">DATEDIFF(minute, GETDATE(), GETUTCDATE()) on the SQL Server when read (UTC − local).</param>
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);
}
/// <summary>
/// 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.
/// </summary>
public static int RoundedOffset(int minutes) => (int)Math.Round(minutes / 15.0, MidpointRounding.AwayFromZero) * 15;
/// <summary>
/// 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.
/// </summary>
/// <param name="configured">SQLInsight:DynamicsAx:LocalTimeZone.</param>
/// <param name="serverOffsetMinutes">The SQL Server's offset (UTC − local) when read, or null when nothing was read.</param>
/// <param name="agentZone">The agent's own zone (TimeZoneInfo.Local; a parameter for tests).</param>
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.");
}
}